mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
consolidated admin controls to settings, added impersonation
This commit is contained in:
@@ -20,6 +20,7 @@ export type AppSession = {
|
||||
id?: string
|
||||
userId?: string
|
||||
activeOrganizationId?: string
|
||||
impersonatedBy?: string | null
|
||||
}
|
||||
} | null
|
||||
|
||||
|
||||
363
apps/sim/app/admin/impersonate/impersonate-client.tsx
Normal file
363
apps/sim/app/admin/impersonate/impersonate-client.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { AlertCircle, ArrowLeft, ChevronLeft, ChevronRight, Loader2, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
|
||||
const USERS_PER_PAGE = 10
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
image: string | null
|
||||
role: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
interface ImpersonateClientProps {
|
||||
currentUserId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts initials from a user's name.
|
||||
*/
|
||||
function getInitials(name: string | undefined | null): string {
|
||||
if (!name?.trim()) return ''
|
||||
const parts = name.trim().split(' ')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
|
||||
}
|
||||
return parts[0][0].toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string to a readable format.
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default function ImpersonateClient({ currentUserId }: ImpersonateClientProps) {
|
||||
const router = useRouter()
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
total: 0,
|
||||
limit: USERS_PER_PAGE,
|
||||
offset: 0,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [impersonatingId, setImpersonatingId] = useState<string | null>(null)
|
||||
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||||
const hasNextPage = currentPage < totalPages
|
||||
const hasPrevPage = currentPage > 1
|
||||
|
||||
const searchUsers = useCallback(
|
||||
async (page = 1) => {
|
||||
if (!searchTerm.trim()) {
|
||||
setUsers([])
|
||||
setPagination({ total: 0, limit: USERS_PER_PAGE, offset: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
setSearching(true)
|
||||
setError(null)
|
||||
|
||||
const offset = (page - 1) * USERS_PER_PAGE
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/impersonate/search?q=${encodeURIComponent(searchTerm.trim())}&limit=${USERS_PER_PAGE}&offset=${offset}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search users')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setUsers(data.users)
|
||||
setPagination(data.pagination)
|
||||
setCurrentPage(page)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to search users')
|
||||
setUsers([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
},
|
||||
[searchTerm]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
searchUsers(1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToPage = useCallback(
|
||||
(page: number) => {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
searchUsers(page)
|
||||
}
|
||||
},
|
||||
[totalPages, searchUsers]
|
||||
)
|
||||
|
||||
const nextPage = useCallback(() => {
|
||||
if (hasNextPage) {
|
||||
searchUsers(currentPage + 1)
|
||||
}
|
||||
}, [hasNextPage, currentPage, searchUsers])
|
||||
|
||||
const prevPage = useCallback(() => {
|
||||
if (hasPrevPage) {
|
||||
searchUsers(currentPage - 1)
|
||||
}
|
||||
}, [hasPrevPage, currentPage, searchUsers])
|
||||
|
||||
const handleImpersonate = async (userId: string) => {
|
||||
if (userId === currentUserId) {
|
||||
setError('You cannot impersonate yourself')
|
||||
return
|
||||
}
|
||||
|
||||
setImpersonatingId(userId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await client.admin.impersonateUser({
|
||||
userId,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Failed to impersonate user')
|
||||
}
|
||||
|
||||
// Redirect to workspace after successful impersonation
|
||||
router.push('/workspace')
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to impersonate user')
|
||||
setImpersonatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div className='border-[var(--border)] border-b bg-[var(--bg-secondary)] px-6 py-4'>
|
||||
<div className='mx-auto flex max-w-5xl items-center gap-4'>
|
||||
<Link href='/workspace'>
|
||||
<Button variant='ghost' size='sm' className='gap-2'>
|
||||
<ArrowLeft className='h-4 w-4' />
|
||||
Back to Workspace
|
||||
</Button>
|
||||
</Link>
|
||||
<div className='h-6 w-px bg-[var(--border)]' />
|
||||
<h1 className='font-semibold text-[var(--text)] text-lg'>User Impersonation</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='mx-auto w-full max-w-5xl p-6'>
|
||||
{/* Search */}
|
||||
<div className='mb-6'>
|
||||
<label
|
||||
htmlFor='user-search'
|
||||
className='mb-2 block font-medium text-[var(--text-secondary)] text-sm'
|
||||
>
|
||||
Search for a user by name or email
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-[var(--text-muted)]' />
|
||||
<Input
|
||||
id='user-search'
|
||||
type='text'
|
||||
placeholder='Enter name or email...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='pl-10'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => searchUsers(1)} disabled={searching || !searchTerm.trim()}>
|
||||
{searching ? <Loader2 className='h-4 w-4 animate-spin' /> : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className='mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4'>
|
||||
<div className='flex gap-3'>
|
||||
<AlertCircle className='h-5 w-5 flex-shrink-0 text-red-500' />
|
||||
<p className='text-red-200 text-sm'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{users.length > 0 && (
|
||||
<div className='rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<p className='text-[var(--text-secondary)] text-sm'>
|
||||
Found {pagination.total} user{pagination.total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className='text-right'>Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Avatar size='sm'>
|
||||
<AvatarImage src={user.image || undefined} alt={user.name} />
|
||||
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium text-[var(--text)]'>{user.name}</span>
|
||||
{user.id === currentUserId && <Badge variant='blue'>You</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-[var(--text-secondary)]'>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{user.role ? (
|
||||
<Badge variant='gray'>{user.role}</Badge>
|
||||
) : (
|
||||
<span className='text-[var(--text-muted)]'>-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className='text-[var(--text-secondary)]'>
|
||||
{formatDate(user.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handleImpersonate(user.id)}
|
||||
disabled={impersonatingId === user.id || user.id === currentUserId}
|
||||
>
|
||||
{impersonatingId === user.id ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-3 w-3 animate-spin' />
|
||||
Impersonating...
|
||||
</>
|
||||
) : (
|
||||
'Impersonate'
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex items-center justify-center border-[var(--border)] border-t px-4 py-3'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={prevPage}
|
||||
disabled={!hasPrevPage || searching}
|
||||
>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
|
||||
<div className='mx-3 flex items-center gap-4'>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let page: number
|
||||
if (totalPages <= 5) {
|
||||
page = i + 1
|
||||
} else if (currentPage <= 3) {
|
||||
page = i + 1
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
page = totalPages - 4 + i
|
||||
} else {
|
||||
page = currentPage - 2 + i
|
||||
}
|
||||
|
||||
if (page < 1 || page > totalPages) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => goToPage(page)}
|
||||
disabled={searching}
|
||||
className={`font-medium text-sm transition-colors hover:text-[var(--text)] disabled:opacity-50 ${
|
||||
page === currentPage ? 'text-[var(--text)]' : 'text-[var(--text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={nextPage}
|
||||
disabled={!hasNextPage || searching}
|
||||
>
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{searchTerm && !searching && users.length === 0 && !error && (
|
||||
<div className='rounded-lg border border-[var(--border)] bg-[var(--bg-secondary)] p-8 text-center'>
|
||||
<p className='text-[var(--text-secondary)]'>No users found matching your search</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
apps/sim/app/admin/impersonate/page.tsx
Normal file
31
apps/sim/app/admin/impersonate/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import ImpersonateClient from './impersonate-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
/**
|
||||
* Admin impersonation page - allows superadmins to impersonate other users.
|
||||
*/
|
||||
export default async function ImpersonatePage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [currentUser] = await db
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (currentUser?.role !== 'superadmin') {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <ImpersonateClient currentUserId={session.user.id} />
|
||||
}
|
||||
94
apps/sim/app/api/admin/impersonate/search/route.ts
Normal file
94
apps/sim/app/api/admin/impersonate/search/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { count, eq, ilike, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('ImpersonateSearchAPI')
|
||||
|
||||
const DEFAULT_LIMIT = 10
|
||||
const MAX_LIMIT = 50
|
||||
|
||||
/**
|
||||
* GET /api/admin/impersonate/search
|
||||
*
|
||||
* Search for users to impersonate. Only accessible by superadmins.
|
||||
*
|
||||
* Query params:
|
||||
* - q: Search term (searches name and email)
|
||||
* - limit: Number of results per page (default: 10, max: 50)
|
||||
* - offset: Number of results to skip (default: 0)
|
||||
*
|
||||
* Response: { users: Array<{ id, name, email, image, role, createdAt }>, pagination: { total, limit, offset } }
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
|
||||
const [currentUser] = await db
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
if (currentUser?.role !== 'superadmin') {
|
||||
return new NextResponse(null, { status: 404 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q')?.trim()
|
||||
const limit = Math.min(
|
||||
Math.max(1, Number.parseInt(searchParams.get('limit') || String(DEFAULT_LIMIT), 10)),
|
||||
MAX_LIMIT
|
||||
)
|
||||
const offset = Math.max(0, Number.parseInt(searchParams.get('offset') || '0', 10))
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
return NextResponse.json({
|
||||
users: [],
|
||||
pagination: { total: 0, limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
const searchPattern = `%${query}%`
|
||||
const whereCondition = or(ilike(user.name, searchPattern), ilike(user.email, searchPattern))
|
||||
|
||||
const [totalResult] = await db.select({ count: count() }).from(user).where(whereCondition)
|
||||
|
||||
const users = await db
|
||||
.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
})
|
||||
.from(user)
|
||||
.where(whereCondition)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
logger.info(`Superadmin ${session.user.id} searched for users with query: ${query}`)
|
||||
|
||||
return NextResponse.json({
|
||||
users: users.map((u) => ({
|
||||
...u,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
})),
|
||||
pagination: {
|
||||
total: totalResult?.count ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to search users for impersonation', { error })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,19 +9,15 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
Database,
|
||||
Filter,
|
||||
FilterX,
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Pause,
|
||||
RepeatIcon,
|
||||
Search,
|
||||
SplitIcon,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Badge,
|
||||
@@ -35,7 +31,6 @@ import {
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
@@ -52,8 +47,6 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { useGeneralStore } from '@/stores/settings/general'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -342,12 +335,6 @@ export function Terminal() {
|
||||
onWrapTextChange: setWrapText,
|
||||
})
|
||||
|
||||
const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false)
|
||||
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
|
||||
const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore()
|
||||
|
||||
const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false)
|
||||
|
||||
const { handleMouseDown } = useTerminalResize()
|
||||
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
|
||||
|
||||
@@ -698,20 +685,6 @@ export function Terminal() {
|
||||
clearCurrentWorkflowConsole()
|
||||
}, [clearCurrentWorkflowConsole])
|
||||
|
||||
const handleTrainingClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isTraining) {
|
||||
stopTraining()
|
||||
} else {
|
||||
toggleTrainingModal()
|
||||
}
|
||||
},
|
||||
[isTraining, stopTraining, toggleTrainingModal]
|
||||
)
|
||||
|
||||
const shouldShowTrainingButton = isTrainingEnvEnabled && showTrainingControls
|
||||
|
||||
/**
|
||||
* Register global keyboard shortcuts for the terminal:
|
||||
* - Mod+D: Clear terminal console for the active workflow
|
||||
@@ -740,14 +713,6 @@ export function Terminal() {
|
||||
setHasHydrated(true)
|
||||
}, [setHasHydrated])
|
||||
|
||||
/**
|
||||
* Check environment variables on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
setIsTrainingEnvEnabled(isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED')))
|
||||
setIsPlaygroundEnabled(isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND')))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Adjust showInput when selected entry changes
|
||||
* Stay on input view if the new entry has input data
|
||||
@@ -1192,48 +1157,6 @@ export function Terminal() {
|
||||
)}
|
||||
{!selectedEntry && (
|
||||
<div className='ml-auto flex items-center gap-[8px]'>
|
||||
{isPlaygroundEnabled && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Link href='/playground'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
aria-label='Component Playground'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<Palette className='h-3 w-3' />
|
||||
</Button>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Component Playground</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{shouldShowTrainingButton && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleTrainingClick}
|
||||
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
|
||||
className={clsx(
|
||||
'!p-1.5 -m-1.5',
|
||||
isTraining && 'text-orange-600 dark:text-orange-400'
|
||||
)}
|
||||
>
|
||||
{isTraining ? (
|
||||
<Pause className='h-3 w-3' />
|
||||
) : (
|
||||
<Database className='h-3 w-3' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -1528,50 +1451,6 @@ export function Terminal() {
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isPlaygroundEnabled && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Link href='/playground'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
aria-label='Component Playground'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
<Palette className='h-[12px] w-[12px]' />
|
||||
</Button>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>Component Playground</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{shouldShowTrainingButton && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleTrainingClick}
|
||||
aria-label={isTraining ? 'Stop training' : 'Train Copilot'}
|
||||
className={clsx(
|
||||
'!p-1.5 -m-1.5',
|
||||
isTraining && 'text-orange-600 dark:text-orange-400'
|
||||
)}
|
||||
>
|
||||
{isTraining ? (
|
||||
<Pause className='h-[12px] w-[12px]' />
|
||||
) : (
|
||||
<Database className='h-[12px] w-[12px]' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<span>{isTraining ? 'Stop Training' : 'Train Copilot'}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
@@ -358,10 +359,15 @@ export function TrainingModal() {
|
||||
<ModalBody className='flex min-h-[400px] flex-col overflow-hidden'>
|
||||
{/* Recording Banner */}
|
||||
{isTraining && (
|
||||
<div className='mb-[16px] rounded-[8px] border bg-orange-50 p-[12px] dark:bg-orange-950/30'>
|
||||
<p className='mb-[8px] font-medium text-[13px] text-orange-700 dark:text-orange-300'>
|
||||
Recording: {currentTitle}
|
||||
</p>
|
||||
<div className='mb-[16px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px]'>
|
||||
<div className='mb-[8px] flex items-center gap-[8px]'>
|
||||
<Badge variant='orange' size='sm'>
|
||||
Recording
|
||||
</Badge>
|
||||
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
{currentTitle}
|
||||
</span>
|
||||
</div>
|
||||
<p className='mb-[12px] text-[12px] text-[var(--text-secondary)]'>
|
||||
{currentPrompt}
|
||||
</p>
|
||||
@@ -384,7 +390,7 @@ export function TrainingModal() {
|
||||
</div>
|
||||
{startSnapshot && (
|
||||
<div className='mt-[8px] flex items-center gap-[12px] text-[12px]'>
|
||||
<span className='text-orange-600 dark:text-orange-400'>Starting state:</span>
|
||||
<span className='text-[var(--text-muted)]'>Starting state:</span>
|
||||
<span className='text-[var(--text-primary)]'>
|
||||
{Object.keys(startSnapshot.blocks).length} blocks
|
||||
</span>
|
||||
@@ -538,9 +544,9 @@ export function TrainingModal() {
|
||||
</div>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
{dataset.sentAt && (
|
||||
<span className='inline-flex items-center rounded-full bg-green-50 px-[8px] py-[2px] text-[11px] text-green-700 ring-1 ring-green-600/20 ring-inset dark:bg-green-900/20 dark:text-green-300'>
|
||||
<CheckCircle2 className='mr-[4px] h-[10px] w-[10px]' /> Sent
|
||||
</span>
|
||||
<Badge variant='green' size='sm' icon={CheckCircle2}>
|
||||
Sent
|
||||
</Badge>
|
||||
)}
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
{dataset.editSequence.length} ops
|
||||
@@ -617,22 +623,9 @@ export function TrainingModal() {
|
||||
|
||||
<div className='flex gap-[8px]'>
|
||||
<Button
|
||||
variant={
|
||||
sentDatasets.has(dataset.id)
|
||||
? 'default'
|
||||
: failedDatasets.has(dataset.id)
|
||||
? 'default'
|
||||
: 'default'
|
||||
}
|
||||
variant='default'
|
||||
onClick={() => handleSendOne(dataset)}
|
||||
disabled={sendingDatasets.has(dataset.id)}
|
||||
className={
|
||||
sentDatasets.has(dataset.id)
|
||||
? '!border-green-500 !text-green-600 dark:!border-green-400 dark:!text-green-400'
|
||||
: failedDatasets.has(dataset.id)
|
||||
? '!border-red-500 !text-red-600 dark:!border-red-400 dark:!text-red-400'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{sendingDatasets.has(dataset.id) ? (
|
||||
'Sending...'
|
||||
@@ -644,7 +637,7 @@ export function TrainingModal() {
|
||||
) : failedDatasets.has(dataset.id) ? (
|
||||
<>
|
||||
<XCircle className='mr-[6px] h-[12px] w-[12px]' />
|
||||
Failed
|
||||
Retry
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -753,11 +746,7 @@ export function TrainingModal() {
|
||||
currentWorkflow.getBlockCount() === 0
|
||||
}
|
||||
variant='tertiary'
|
||||
className={cn(
|
||||
'w-full',
|
||||
liveWorkflowSent && '!bg-green-600 !text-white hover:!bg-green-700',
|
||||
liveWorkflowFailed && '!bg-red-600 !text-white hover:!bg-red-700'
|
||||
)}
|
||||
className='w-full'
|
||||
>
|
||||
{sendingLiveWorkflow ? (
|
||||
'Sending...'
|
||||
@@ -769,7 +758,7 @@ export function TrainingModal() {
|
||||
) : liveWorkflowFailed ? (
|
||||
<>
|
||||
<XCircle className='mr-[6px] h-[14px] w-[14px]' />
|
||||
Failed - Try Again
|
||||
Retry
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -780,19 +769,15 @@ export function TrainingModal() {
|
||||
</Button>
|
||||
|
||||
{liveWorkflowSent && (
|
||||
<div className='rounded-[8px] border bg-green-50 p-[12px] dark:bg-green-950/30'>
|
||||
<p className='text-[13px] text-green-700 dark:text-green-300'>
|
||||
Workflow state sent successfully!
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-center text-[12px] text-[var(--text-secondary)]'>
|
||||
Workflow state sent successfully.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{liveWorkflowFailed && (
|
||||
<div className='rounded-[8px] border bg-red-50 p-[12px] dark:bg-red-950/30'>
|
||||
<p className='text-[13px] text-red-700 dark:text-red-300'>
|
||||
Failed to send workflow state. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
<p className='text-center text-[12px] text-[var(--text-error)]'>
|
||||
Failed to send workflow state. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Loader2, UserCircle, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
|
||||
const logger = createLogger('ImpersonationIndicator')
|
||||
|
||||
interface ImpersonationIndicatorProps {
|
||||
userName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicator shown in the sidebar when an admin is impersonating another user.
|
||||
* Styled similarly to UsageIndicator for visual consistency.
|
||||
*/
|
||||
export function ImpersonationIndicator({ userName }: ImpersonationIndicatorProps) {
|
||||
const router = useRouter()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleStopImpersonating = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await client.admin.stopImpersonating()
|
||||
router.push('/workspace')
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop impersonating', { error })
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-shrink-0 flex-col gap-[6px] border-amber-500/30 border-t bg-amber-500/10 px-[13.5px] pt-[8px] pb-[10px]'>
|
||||
<div className='flex items-center justify-between gap-[8px]'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[6px]'>
|
||||
<UserCircle className='h-[14px] w-[14px] flex-shrink-0 text-amber-500' />
|
||||
<span className='truncate font-medium text-[12px] text-amber-500'>
|
||||
Impersonating {userName}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-amber-500 hover:bg-amber-500/20 hover:text-amber-400'
|
||||
onClick={handleStopImpersonating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className='h-[12px] w-[12px] animate-spin' />
|
||||
) : (
|
||||
<X className='h-[12px] w-[12px]' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ImpersonationIndicator } from './impersonation-indicator'
|
||||
@@ -1,4 +1,5 @@
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { ImpersonationIndicator } from './impersonation-indicator'
|
||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||
export { SearchModal } from './search-modal/search-modal'
|
||||
export { SettingsModal } from './settings-modal/settings-modal'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Camera, Check, Pencil } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -28,6 +29,7 @@ import { useAdminStatus } from '@/hooks/queries/admin-status'
|
||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
|
||||
import { clearUserData } from '@/stores'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
|
||||
const logger = createLogger('General')
|
||||
|
||||
@@ -134,10 +136,13 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
const isLoading = isProfileLoading || isSettingsLoading
|
||||
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const isPlaygroundEnabled = isTruthy(getEnv('NEXT_PUBLIC_ENABLE_PLAYGROUND'))
|
||||
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
|
||||
|
||||
const { data: adminStatus, isLoading: loadingAdminStatus } = useAdminStatus(!!session?.user?.id)
|
||||
const hasAdminPrivileges = adminStatus?.hasAdminPrivileges ?? false
|
||||
const { data: adminStatus } = useAdminStatus(!!session?.user?.id)
|
||||
const isSuperadmin = adminStatus?.role === 'superadmin'
|
||||
|
||||
const { toggleModal: toggleTrainingModal } = useCopilotTrainingStore()
|
||||
|
||||
const [name, setName] = useState(profile?.name || '')
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
@@ -303,19 +308,13 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrainingControlsChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.showTrainingControls && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'showTrainingControls', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleErrorNotificationsChange = async (checked: boolean) => {
|
||||
if (checked !== settings?.errorNotificationsEnabled && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'errorNotificationsEnabled', value: checked })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuperUserModeToggle = async (checked: boolean) => {
|
||||
const handleAdminModeToggle = async (checked: boolean) => {
|
||||
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
|
||||
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
|
||||
}
|
||||
@@ -535,25 +534,49 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
time.
|
||||
</p>
|
||||
|
||||
{isTrainingEnabled && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='training-controls'>Training controls</Label>
|
||||
<Switch
|
||||
id='training-controls'
|
||||
checked={settings?.showTrainingControls ?? false}
|
||||
onCheckedChange={handleTrainingControlsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingAdminStatus && hasAdminPrivileges && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='admin-mode'>Admin mode</Label>
|
||||
<Switch
|
||||
id='admin-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
/>
|
||||
{(isSuperadmin || isPlaygroundEnabled || isTrainingEnabled) && (
|
||||
<div className='flex flex-col gap-[8px] border-t pt-[16px]'>
|
||||
<p className='font-medium text-[12px] text-[var(--text-tertiary)]'>Developer Tools</p>
|
||||
<div className='flex flex-wrap gap-[8px]'>
|
||||
{isSuperadmin && (
|
||||
<Link href='/admin/impersonate' onClick={() => onOpenChange?.(false)}>
|
||||
<Button variant='active' size='sm'>
|
||||
Impersonate User
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isPlaygroundEnabled && (
|
||||
<Link href='/playground' onClick={() => onOpenChange?.(false)}>
|
||||
<Button variant='active' size='sm'>
|
||||
Component Playground
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isTrainingEnabled && (
|
||||
<Button
|
||||
variant='active'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
onOpenChange?.(false)
|
||||
toggleTrainingModal()
|
||||
}}
|
||||
>
|
||||
Copilot Training
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isSuperadmin && (
|
||||
<div className='mt-[4px] flex items-center justify-between'>
|
||||
<Label htmlFor='admin-mode' className='text-[12px]'>
|
||||
Admin mode
|
||||
</Label>
|
||||
<Switch
|
||||
id='admin-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleAdminModeToggle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||
import {
|
||||
HelpModal,
|
||||
ImpersonationIndicator,
|
||||
NavItemContextMenu,
|
||||
SearchModal,
|
||||
SettingsModal,
|
||||
@@ -643,6 +644,13 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Impersonation Indicator */}
|
||||
{sessionData?.session?.impersonatedBy && (
|
||||
<ImpersonationIndicator
|
||||
userName={sessionData?.user?.name || sessionData?.user?.email || 'Unknown'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Usage Indicator */}
|
||||
{isBillingEnabled && <UsageIndicator />}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/s
|
||||
|
||||
export const client = createAuthClient({
|
||||
baseURL: getBaseUrl(),
|
||||
fetchOptions: {
|
||||
credentials: 'include',
|
||||
},
|
||||
plugins: [
|
||||
emailOTPClient(),
|
||||
genericOAuthClient(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as schema from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { getSessionFromCtx } from 'better-auth/api'
|
||||
import { nextCookies } from 'better-auth/next-js'
|
||||
import {
|
||||
admin,
|
||||
@@ -522,7 +523,7 @@ export const auth = betterAuth({
|
||||
|
||||
// Impersonation authorization: only superadmin users can impersonate
|
||||
if (ctx.path.startsWith('/admin/impersonate-user')) {
|
||||
const session = ctx.context.session
|
||||
const session = await getSessionFromCtx(ctx)
|
||||
if (!session?.user?.id) {
|
||||
throw new Error('You must be logged in to impersonate users.')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user