mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-25 14:58:14 -05:00
Compare commits
5 Commits
fix/docs
...
feat/imper
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82fba6dc07 | ||
|
|
29ab351d0d | ||
|
|
432a40efc2 | ||
|
|
73873bb4c6 | ||
|
|
197ada5df2 |
@@ -20,6 +20,7 @@ export type AppSession = {
|
|||||||
id?: string
|
id?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
activeOrganizationId?: string
|
activeOrganizationId?: string
|
||||||
|
impersonatedBy?: string | null
|
||||||
}
|
}
|
||||||
} | 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,15 +22,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
|
const hasAdminPrivileges =
|
||||||
|
currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||||
|
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
if (!hasAdminPrivileges) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-admin user attempted to verify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only admin users can verify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if creator exists
|
|
||||||
const existingCreator = await db
|
const existingCreator = await db
|
||||||
.select()
|
.select()
|
||||||
.from(templateCreators)
|
.from(templateCreators)
|
||||||
@@ -42,7 +42,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update creator verified status to true
|
|
||||||
await db
|
await db
|
||||||
.update(templateCreators)
|
.update(templateCreators)
|
||||||
.set({ verified: true, updatedAt: new Date() })
|
.set({ verified: true, updatedAt: new Date() })
|
||||||
@@ -75,15 +74,15 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a super user
|
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
|
const hasAdminPrivileges =
|
||||||
|
currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||||
|
|
||||||
if (!currentUser[0]?.isSuperUser) {
|
if (!hasAdminPrivileges) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
logger.warn(`[${requestId}] Non-admin user attempted to unverify creator: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
return NextResponse.json({ error: 'Only admin users can unverify creators' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if creator exists
|
|
||||||
const existingCreator = await db
|
const existingCreator = await db
|
||||||
.select()
|
.select()
|
||||||
.from(templateCreators)
|
.from(templateCreators)
|
||||||
@@ -95,7 +94,6 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update creator verified status to false
|
|
||||||
await db
|
await db
|
||||||
.update(templateCreators)
|
.update(templateCreators)
|
||||||
.set({ verified: false, updatedAt: new Date() })
|
.set({ verified: false, updatedAt: new Date() })
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyAdminPrivileges } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateApprovalAPI')
|
const logger = createLogger('TemplateApprovalAPI')
|
||||||
|
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/templates/[id]/approve - Approve a template (super users only)
|
* POST /api/templates/[id]/approve - Approve a template (admin users only)
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
@@ -25,10 +25,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!hasAdminPrivileges) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
logger.warn(`[${requestId}] Non-admin user attempted to approve template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only admin users can approve templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
.set({ status: 'approved', updatedAt: new Date() })
|
.set({ status: 'approved', updatedAt: new Date() })
|
||||||
.where(eq(templates.id, id))
|
.where(eq(templates.id, id))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Template approved: ${id} by super user: ${session.user.id}`)
|
logger.info(`[${requestId}] Template approved: ${id} by admin: ${session.user.id}`)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Template approved successfully',
|
message: 'Template approved successfully',
|
||||||
@@ -55,7 +55,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/templates/[id]/approve - Unapprove a template (super users only)
|
* DELETE /api/templates/[id]/approve - Unapprove a template (admin users only)
|
||||||
*/
|
*/
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -71,10 +71,10 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!hasAdminPrivileges) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-admin user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only admin users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||||
@@ -88,7 +88,7 @@ export async function DELETE(
|
|||||||
.set({ status: 'rejected', updatedAt: new Date() })
|
.set({ status: 'rejected', updatedAt: new Date() })
|
||||||
.where(eq(templates.id, id))
|
.where(eq(templates.id, id))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Template rejected: ${id} by super user: ${session.user.id}`)
|
logger.info(`[${requestId}] Template rejected: ${id} by admin: ${session.user.id}`)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Template rejected successfully',
|
message: 'Template rejected successfully',
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
import { verifyAdminPrivileges } from '@/lib/templates/permissions'
|
||||||
|
|
||||||
const logger = createLogger('TemplateRejectionAPI')
|
const logger = createLogger('TemplateRejectionAPI')
|
||||||
|
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/templates/[id]/reject - Reject a template (super users only)
|
* POST /api/templates/[id]/reject - Reject a template (admin users only)
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
@@ -25,10 +25,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||||
if (!isSuperUser) {
|
if (!hasAdminPrivileges) {
|
||||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
logger.warn(`[${requestId}] Non-admin user attempted to reject template: ${id}`)
|
||||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
return NextResponse.json({ error: 'Only admin users can reject templates' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
.set({ status: 'rejected', updatedAt: new Date() })
|
.set({ status: 'rejected', updatedAt: new Date() })
|
||||||
.where(eq(templates.id, id))
|
.where(eq(templates.id, id))
|
||||||
|
|
||||||
logger.info(`[${requestId}] Template rejected: ${id} by super user: ${session.user.id}`)
|
logger.info(`[${requestId}] Template rejected: ${id} by admin: ${session.user.id}`)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Template rejected successfully',
|
message: 'Template rejected successfully',
|
||||||
|
|||||||
@@ -23,13 +23,10 @@ const logger = createLogger('TemplatesAPI')
|
|||||||
|
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
// Function to sanitize sensitive data from workflow state
|
|
||||||
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
|
|
||||||
function sanitizeWorkflowState(state: any): any {
|
function sanitizeWorkflowState(state: any): any {
|
||||||
return sanitizeCredentials(state)
|
return sanitizeCredentials(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schema for creating a template
|
|
||||||
const CreateTemplateSchema = z.object({
|
const CreateTemplateSchema = z.object({
|
||||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||||
@@ -43,7 +40,6 @@ const CreateTemplateSchema = z.object({
|
|||||||
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
|
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Schema for query parameters
|
|
||||||
const QueryParamsSchema = z.object({
|
const QueryParamsSchema = z.object({
|
||||||
limit: z.coerce.number().optional().default(50),
|
limit: z.coerce.number().optional().default(50),
|
||||||
offset: z.coerce.number().optional().default(0),
|
offset: z.coerce.number().optional().default(0),
|
||||||
@@ -69,31 +65,21 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||||
|
|
||||||
// Check if user is a super user
|
|
||||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
const isSuperUser = currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||||
|
|
||||||
// Build query conditions
|
|
||||||
const conditions = []
|
const conditions = []
|
||||||
|
|
||||||
// Apply workflow filter if provided (for getting template by workflow)
|
|
||||||
// When fetching by workflowId, we want to get the template regardless of status
|
|
||||||
// This is used by the deploy modal to check if a template exists
|
|
||||||
if (params.workflowId) {
|
if (params.workflowId) {
|
||||||
conditions.push(eq(templates.workflowId, params.workflowId))
|
conditions.push(eq(templates.workflowId, params.workflowId))
|
||||||
// Don't apply status filter when fetching by workflowId - we want to show
|
|
||||||
// the template to its owner even if it's pending
|
|
||||||
} else {
|
} else {
|
||||||
// Apply status filter - only approved templates for non-super users
|
|
||||||
if (params.status) {
|
if (params.status) {
|
||||||
conditions.push(eq(templates.status, params.status))
|
conditions.push(eq(templates.status, params.status))
|
||||||
} else if (!isSuperUser || !params.includeAllStatuses) {
|
} else if (!isSuperUser || !params.includeAllStatuses) {
|
||||||
// Non-super users and super users without includeAllStatuses flag see only approved templates
|
|
||||||
conditions.push(eq(templates.status, 'approved'))
|
conditions.push(eq(templates.status, 'approved'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply search filter if provided
|
|
||||||
if (params.search) {
|
if (params.search) {
|
||||||
const searchTerm = `%${params.search}%`
|
const searchTerm = `%${params.search}%`
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -104,10 +90,8 @@ export async function GET(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine conditions
|
|
||||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||||
|
|
||||||
// Apply ordering, limit, and offset with star information
|
|
||||||
const results = await db
|
const results = await db
|
||||||
.select({
|
.select({
|
||||||
id: templates.id,
|
id: templates.id,
|
||||||
@@ -138,7 +122,6 @@ export async function GET(request: NextRequest) {
|
|||||||
.limit(params.limit)
|
.limit(params.limit)
|
||||||
.offset(params.offset)
|
.offset(params.offset)
|
||||||
|
|
||||||
// Get total count for pagination
|
|
||||||
const totalCount = await db
|
const totalCount = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(templates)
|
.from(templates)
|
||||||
@@ -191,7 +174,6 @@ export async function POST(request: NextRequest) {
|
|||||||
workflowId: data.workflowId,
|
workflowId: data.workflowId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify the workflow exists and belongs to the user
|
|
||||||
const workflowExists = await db
|
const workflowExists = await db
|
||||||
.select({ id: workflow.id })
|
.select({ id: workflow.id })
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
@@ -218,7 +200,6 @@ export async function POST(request: NextRequest) {
|
|||||||
const templateId = uuidv4()
|
const templateId = uuidv4()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Get the active deployment version for the workflow to copy its state
|
|
||||||
const activeVersion = await db
|
const activeVersion = await db
|
||||||
.select({
|
.select({
|
||||||
id: workflowDeploymentVersion.id,
|
id: workflowDeploymentVersion.id,
|
||||||
@@ -243,10 +224,8 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the state includes workflow variables (if not already included)
|
|
||||||
let stateWithVariables = activeVersion[0].state as any
|
let stateWithVariables = activeVersion[0].state as any
|
||||||
if (stateWithVariables && !stateWithVariables.variables) {
|
if (stateWithVariables && !stateWithVariables.variables) {
|
||||||
// Fetch workflow variables if not in deployment version
|
|
||||||
const [workflowRecord] = await db
|
const [workflowRecord] = await db
|
||||||
.select({ variables: workflow.variables })
|
.select({ variables: workflow.variables })
|
||||||
.from(workflow)
|
.from(workflow)
|
||||||
@@ -259,10 +238,8 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract credential requirements before sanitizing
|
|
||||||
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
|
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
|
||||||
|
|
||||||
// Sanitize the workflow state to remove all credential values
|
|
||||||
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
|
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
|
||||||
|
|
||||||
const newTemplate = {
|
const newTemplate = {
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
const logger = createLogger('SuperUserAPI')
|
const logger = createLogger('AdminStatusAPI')
|
||||||
|
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
|
|
||||||
// GET /api/user/super-user - Check if current user is a super user (database status)
|
/**
|
||||||
|
* GET /api/user/admin-status - Check if current user has admin privileges
|
||||||
|
* Returns hasAdminPrivileges: true if user role is 'admin' or 'superadmin'
|
||||||
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
|
logger.warn(`[${requestId}] Unauthorized admin status check attempt`)
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = await db
|
const currentUser = await db
|
||||||
.select({ isSuperUser: user.isSuperUser })
|
.select({ role: user.role })
|
||||||
.from(user)
|
.from(user)
|
||||||
.where(eq(user.id, session.user.id))
|
.where(eq(user.id, session.user.id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -32,11 +35,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const role = currentUser[0].role
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
isSuperUser: currentUser[0].isSuperUser,
|
hasAdminPrivileges: role === 'admin' || role === 'superadmin',
|
||||||
|
role,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error checking super user status`, error)
|
logger.error(`[${requestId}] Error checking admin status`, error)
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
* GET /api/v1/admin/users/:id - Get user details
|
* GET /api/v1/admin/users/:id - Get user details
|
||||||
* GET /api/v1/admin/users/:id/billing - Get user billing info
|
* GET /api/v1/admin/users/:id/billing - Get user billing info
|
||||||
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
|
* PATCH /api/v1/admin/users/:id/billing - Update user billing (limit, blocked)
|
||||||
|
* GET /api/v1/admin/users/:id/role - Get user role
|
||||||
|
* PATCH /api/v1/admin/users/:id/role - Update user role (user, admin, superadmin)
|
||||||
*
|
*
|
||||||
* Workspaces:
|
* Workspaces:
|
||||||
* GET /api/v1/admin/workspaces - List all workspaces
|
* GET /api/v1/admin/workspaces - List all workspaces
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export interface AdminUser {
|
|||||||
email: string
|
email: string
|
||||||
emailVerified: boolean
|
emailVerified: boolean
|
||||||
image: string | null
|
image: string | null
|
||||||
|
role: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -116,6 +117,7 @@ export function toAdminUser(dbUser: DbUser): AdminUser {
|
|||||||
email: dbUser.email,
|
email: dbUser.email,
|
||||||
emailVerified: dbUser.emailVerified,
|
emailVerified: dbUser.emailVerified,
|
||||||
image: dbUser.image,
|
image: dbUser.image,
|
||||||
|
role: dbUser.role,
|
||||||
createdAt: dbUser.createdAt.toISOString(),
|
createdAt: dbUser.createdAt.toISOString(),
|
||||||
updatedAt: dbUser.updatedAt.toISOString(),
|
updatedAt: dbUser.updatedAt.toISOString(),
|
||||||
}
|
}
|
||||||
|
|||||||
98
apps/sim/app/api/v1/admin/users/[id]/role/route.ts
Normal file
98
apps/sim/app/api/v1/admin/users/[id]/role/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/v1/admin/users/[id]/role
|
||||||
|
*
|
||||||
|
* Get a user's current role.
|
||||||
|
*
|
||||||
|
* Response: AdminSingleResponse<{ role: string | null }>
|
||||||
|
*
|
||||||
|
* PATCH /api/v1/admin/users/[id]/role
|
||||||
|
*
|
||||||
|
* Update a user's role.
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* - role: 'user' | 'admin' | 'superadmin' - The role to assign
|
||||||
|
*
|
||||||
|
* Response: AdminSingleResponse<AdminUser>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '@sim/db'
|
||||||
|
import { user } from '@sim/db/schema'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||||
|
import {
|
||||||
|
badRequestResponse,
|
||||||
|
internalErrorResponse,
|
||||||
|
notFoundResponse,
|
||||||
|
singleResponse,
|
||||||
|
} from '@/app/api/v1/admin/responses'
|
||||||
|
import { toAdminUser } from '@/app/api/v1/admin/types'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminUserRoleAPI')
|
||||||
|
|
||||||
|
const VALID_ROLES = ['user', 'admin', 'superadmin'] as const
|
||||||
|
type ValidRole = (typeof VALID_ROLES)[number]
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||||
|
const { id: userId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [userData] = await db
|
||||||
|
.select({ role: user.role })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return notFoundResponse('User')
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Admin API: Retrieved role for user ${userId}`)
|
||||||
|
|
||||||
|
return singleResponse({ role: userData.role })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to get user role', { error, userId })
|
||||||
|
return internalErrorResponse('Failed to get user role')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PATCH = withAdminAuthParams<RouteParams>(async (request, context) => {
|
||||||
|
const { id: userId } = await context.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return notFoundResponse('User')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.role === undefined) {
|
||||||
|
return badRequestResponse('role is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_ROLES.includes(body.role)) {
|
||||||
|
return badRequestResponse(`Invalid role. Must be one of: ${VALID_ROLES.join(', ')}`, {
|
||||||
|
validRoles: VALID_ROLES,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(user)
|
||||||
|
.set({ role: body.role as ValidRole, updatedAt: new Date() })
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
logger.info(`Admin API: Updated user ${userId} role to ${body.role}`)
|
||||||
|
|
||||||
|
return singleResponse(toAdminUser(updated))
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Admin API: Failed to update user role', { error, userId })
|
||||||
|
return internalErrorResponse('Failed to update user role')
|
||||||
|
}
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
|||||||
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
||||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||||
import { getBlock } from '@/blocks/registry'
|
import { getBlock } from '@/blocks/registry'
|
||||||
|
import { useAdminStatus } from '@/hooks/queries/admin-status'
|
||||||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||||
|
|
||||||
const logger = createLogger('TemplateDetails')
|
const logger = createLogger('TemplateDetails')
|
||||||
@@ -150,7 +151,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||||
Array<{ organizationId: string; role: string }>
|
Array<{ organizationId: string; role: string }>
|
||||||
>([])
|
>([])
|
||||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
const { data: adminStatus } = useAdminStatus(!!session?.user?.id)
|
||||||
|
const hasAdminPrivileges = adminStatus?.hasAdminPrivileges ?? false
|
||||||
const [isUsing, setIsUsing] = useState(false)
|
const [isUsing, setIsUsing] = useState(false)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isApproving, setIsApproving] = useState(false)
|
const [isApproving, setIsApproving] = useState(false)
|
||||||
@@ -188,21 +190,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()
|
fetchUserOrganizations()
|
||||||
}, [currentUserId])
|
}, [currentUserId])
|
||||||
|
|
||||||
@@ -650,7 +637,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
{/* Approve/Reject buttons for super users */}
|
{/* Approve/Reject buttons for super users */}
|
||||||
{isSuperUser && template.status === 'pending' && (
|
{hasAdminPrivileges && template.status === 'pending' && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant='active'
|
variant='active'
|
||||||
@@ -974,7 +961,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
<h3 className='font-sans font-semibold text-base text-foreground'>
|
<h3 className='font-sans font-semibold text-base text-foreground'>
|
||||||
About the Creator
|
About the Creator
|
||||||
</h3>
|
</h3>
|
||||||
{isSuperUser && template.creator && (
|
{hasAdminPrivileges && template.creator && (
|
||||||
<Button
|
<Button
|
||||||
variant={template.creator.verified ? 'active' : 'default'}
|
variant={template.creator.verified ? 'active' : 'default'}
|
||||||
onClick={handleToggleVerification}
|
onClick={handleToggleVerification}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
|||||||
redirect(`/workspace/${workspaceId}`)
|
redirect(`/workspace/${workspaceId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine effective super user (DB flag AND UI mode enabled)
|
// Determine effective super user (admin/superadmin role AND UI mode enabled)
|
||||||
const currentUser = await db
|
const currentUser = await db
|
||||||
.select({ isSuperUser: user.isSuperUser })
|
.select({ role: user.role })
|
||||||
.from(user)
|
.from(user)
|
||||||
.where(eq(user.id, session.user.id))
|
.where(eq(user.id, session.user.id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -51,7 +51,7 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
|||||||
.where(eq(settings.userId, session.user.id))
|
.where(eq(settings.userId, session.user.id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
const isSuperUser = currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||||
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
|
||||||
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
const effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,15 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Database,
|
|
||||||
Filter,
|
Filter,
|
||||||
FilterX,
|
FilterX,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Palette,
|
|
||||||
Pause,
|
|
||||||
RepeatIcon,
|
RepeatIcon,
|
||||||
Search,
|
Search,
|
||||||
SplitIcon,
|
SplitIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -35,7 +31,6 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
|
||||||
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
|
import { formatTimeWithSeconds } from '@/lib/core/utils/formatting'
|
||||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
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 { getBlock } from '@/blocks'
|
||||||
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
|
||||||
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
|
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 type { ConsoleEntry } from '@/stores/terminal'
|
||||||
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
import { useTerminalConsoleStore, useTerminalStore } from '@/stores/terminal'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -342,12 +335,6 @@ export function Terminal() {
|
|||||||
onWrapTextChange: setWrapText,
|
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 } = useTerminalResize()
|
||||||
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
|
const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize()
|
||||||
|
|
||||||
@@ -698,20 +685,6 @@ export function Terminal() {
|
|||||||
clearCurrentWorkflowConsole()
|
clearCurrentWorkflowConsole()
|
||||||
}, [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:
|
* Register global keyboard shortcuts for the terminal:
|
||||||
* - Mod+D: Clear terminal console for the active workflow
|
* - Mod+D: Clear terminal console for the active workflow
|
||||||
@@ -740,14 +713,6 @@ export function Terminal() {
|
|||||||
setHasHydrated(true)
|
setHasHydrated(true)
|
||||||
}, [setHasHydrated])
|
}, [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
|
* Adjust showInput when selected entry changes
|
||||||
* Stay on input view if the new entry has input data
|
* Stay on input view if the new entry has input data
|
||||||
@@ -1192,48 +1157,6 @@ export function Terminal() {
|
|||||||
)}
|
)}
|
||||||
{!selectedEntry && (
|
{!selectedEntry && (
|
||||||
<div className='ml-auto flex items-center gap-[8px]'>
|
<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 && (
|
{hasActiveFilters && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
@@ -1528,50 +1451,6 @@ export function Terminal() {
|
|||||||
</Tooltip.Root>
|
</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.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Input,
|
Input,
|
||||||
@@ -358,10 +359,15 @@ export function TrainingModal() {
|
|||||||
<ModalBody className='flex min-h-[400px] flex-col overflow-hidden'>
|
<ModalBody className='flex min-h-[400px] flex-col overflow-hidden'>
|
||||||
{/* Recording Banner */}
|
{/* Recording Banner */}
|
||||||
{isTraining && (
|
{isTraining && (
|
||||||
<div className='mb-[16px] rounded-[8px] border bg-orange-50 p-[12px] dark:bg-orange-950/30'>
|
<div className='mb-[16px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px]'>
|
||||||
<p className='mb-[8px] font-medium text-[13px] text-orange-700 dark:text-orange-300'>
|
<div className='mb-[8px] flex items-center gap-[8px]'>
|
||||||
Recording: {currentTitle}
|
<Badge variant='orange' size='sm'>
|
||||||
</p>
|
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)]'>
|
<p className='mb-[12px] text-[12px] text-[var(--text-secondary)]'>
|
||||||
{currentPrompt}
|
{currentPrompt}
|
||||||
</p>
|
</p>
|
||||||
@@ -384,7 +390,7 @@ export function TrainingModal() {
|
|||||||
</div>
|
</div>
|
||||||
{startSnapshot && (
|
{startSnapshot && (
|
||||||
<div className='mt-[8px] flex items-center gap-[12px] text-[12px]'>
|
<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)]'>
|
<span className='text-[var(--text-primary)]'>
|
||||||
{Object.keys(startSnapshot.blocks).length} blocks
|
{Object.keys(startSnapshot.blocks).length} blocks
|
||||||
</span>
|
</span>
|
||||||
@@ -538,9 +544,9 @@ export function TrainingModal() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-[12px]'>
|
<div className='flex items-center gap-[12px]'>
|
||||||
{dataset.sentAt && (
|
{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'>
|
<Badge variant='green' size='sm' icon={CheckCircle2}>
|
||||||
<CheckCircle2 className='mr-[4px] h-[10px] w-[10px]' /> Sent
|
Sent
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||||
{dataset.editSequence.length} ops
|
{dataset.editSequence.length} ops
|
||||||
@@ -617,22 +623,9 @@ export function TrainingModal() {
|
|||||||
|
|
||||||
<div className='flex gap-[8px]'>
|
<div className='flex gap-[8px]'>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant='default'
|
||||||
sentDatasets.has(dataset.id)
|
|
||||||
? 'default'
|
|
||||||
: failedDatasets.has(dataset.id)
|
|
||||||
? 'default'
|
|
||||||
: 'default'
|
|
||||||
}
|
|
||||||
onClick={() => handleSendOne(dataset)}
|
onClick={() => handleSendOne(dataset)}
|
||||||
disabled={sendingDatasets.has(dataset.id)}
|
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) ? (
|
{sendingDatasets.has(dataset.id) ? (
|
||||||
'Sending...'
|
'Sending...'
|
||||||
@@ -644,7 +637,7 @@ export function TrainingModal() {
|
|||||||
) : failedDatasets.has(dataset.id) ? (
|
) : failedDatasets.has(dataset.id) ? (
|
||||||
<>
|
<>
|
||||||
<XCircle className='mr-[6px] h-[12px] w-[12px]' />
|
<XCircle className='mr-[6px] h-[12px] w-[12px]' />
|
||||||
Failed
|
Retry
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -753,11 +746,7 @@ export function TrainingModal() {
|
|||||||
currentWorkflow.getBlockCount() === 0
|
currentWorkflow.getBlockCount() === 0
|
||||||
}
|
}
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
className={cn(
|
className='w-full'
|
||||||
'w-full',
|
|
||||||
liveWorkflowSent && '!bg-green-600 !text-white hover:!bg-green-700',
|
|
||||||
liveWorkflowFailed && '!bg-red-600 !text-white hover:!bg-red-700'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{sendingLiveWorkflow ? (
|
{sendingLiveWorkflow ? (
|
||||||
'Sending...'
|
'Sending...'
|
||||||
@@ -769,7 +758,7 @@ export function TrainingModal() {
|
|||||||
) : liveWorkflowFailed ? (
|
) : liveWorkflowFailed ? (
|
||||||
<>
|
<>
|
||||||
<XCircle className='mr-[6px] h-[14px] w-[14px]' />
|
<XCircle className='mr-[6px] h-[14px] w-[14px]' />
|
||||||
Failed - Try Again
|
Retry
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -780,19 +769,15 @@ export function TrainingModal() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{liveWorkflowSent && (
|
{liveWorkflowSent && (
|
||||||
<div className='rounded-[8px] border bg-green-50 p-[12px] dark:bg-green-950/30'>
|
<p className='text-center text-[12px] text-[var(--text-secondary)]'>
|
||||||
<p className='text-[13px] text-green-700 dark:text-green-300'>
|
Workflow state sent successfully.
|
||||||
Workflow state sent successfully!
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{liveWorkflowFailed && (
|
{liveWorkflowFailed && (
|
||||||
<div className='rounded-[8px] border bg-red-50 p-[12px] dark:bg-red-950/30'>
|
<p className='text-center text-[12px] text-[var(--text-error)]'>
|
||||||
<p className='text-[13px] text-red-700 dark:text-red-300'>
|
Failed to send workflow state. Please try again.
|
||||||
Failed to send workflow state. Please try again.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</ModalTabsContent>
|
</ModalTabsContent>
|
||||||
</ModalBody>
|
</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 { HelpModal } from './help-modal/help-modal'
|
||||||
|
export { ImpersonationIndicator } from './impersonation-indicator'
|
||||||
export { NavItemContextMenu } from './nav-item-context-menu'
|
export { NavItemContextMenu } from './nav-item-context-menu'
|
||||||
export { SearchModal } from './search-modal/search-modal'
|
export { SearchModal } from './search-modal/search-modal'
|
||||||
export { SettingsModal } from './settings-modal/settings-modal'
|
export { SettingsModal } from './settings-modal/settings-modal'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { Camera, Check, Pencil } from 'lucide-react'
|
import { Camera, Check, Pencil } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -24,9 +25,11 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
|
|||||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/hooks/use-profile-picture-upload'
|
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/hooks/use-profile-picture-upload'
|
||||||
|
import { useAdminStatus } from '@/hooks/queries/admin-status'
|
||||||
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
|
||||||
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
|
import { useUpdateUserProfile, useUserProfile } from '@/hooks/queries/user-profile'
|
||||||
import { clearUserData } from '@/stores'
|
import { clearUserData } from '@/stores'
|
||||||
|
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||||
|
|
||||||
const logger = createLogger('General')
|
const logger = createLogger('General')
|
||||||
|
|
||||||
@@ -133,10 +136,13 @@ export function General({ onOpenChange }: GeneralProps) {
|
|||||||
const isLoading = isProfileLoading || isSettingsLoading
|
const isLoading = isProfileLoading || isSettingsLoading
|
||||||
|
|
||||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
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 isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
|
||||||
|
|
||||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
const { data: adminStatus } = useAdminStatus(!!session?.user?.id)
|
||||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
const isSuperadmin = adminStatus?.role === 'superadmin'
|
||||||
|
|
||||||
|
const { toggleModal: toggleTrainingModal } = useCopilotTrainingStore()
|
||||||
|
|
||||||
const [name, setName] = useState(profile?.name || '')
|
const [name, setName] = useState(profile?.name || '')
|
||||||
const [isEditingName, setIsEditingName] = useState(false)
|
const [isEditingName, setIsEditingName] = useState(false)
|
||||||
@@ -157,26 +163,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
|||||||
}
|
}
|
||||||
}, [profile?.name])
|
}, [profile?.name])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSuperUserStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user/super-user')
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setIsSuperUser(data.isSuperUser)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch super user status:', error)
|
|
||||||
} finally {
|
|
||||||
setLoadingSuperUser(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (session?.user?.id) {
|
|
||||||
fetchSuperUserStatus()
|
|
||||||
}
|
|
||||||
}, [session?.user?.id])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
previewUrl: profilePictureUrl,
|
previewUrl: profilePictureUrl,
|
||||||
fileInputRef: profilePictureInputRef,
|
fileInputRef: profilePictureInputRef,
|
||||||
@@ -322,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) => {
|
const handleErrorNotificationsChange = async (checked: boolean) => {
|
||||||
if (checked !== settings?.errorNotificationsEnabled && !updateSetting.isPending) {
|
if (checked !== settings?.errorNotificationsEnabled && !updateSetting.isPending) {
|
||||||
await updateSetting.mutateAsync({ key: 'errorNotificationsEnabled', value: checked })
|
await updateSetting.mutateAsync({ key: 'errorNotificationsEnabled', value: checked })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuperUserModeToggle = async (checked: boolean) => {
|
const handleAdminModeToggle = async (checked: boolean) => {
|
||||||
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
|
if (checked !== settings?.superUserModeEnabled && !updateSetting.isPending) {
|
||||||
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
|
await updateSetting.mutateAsync({ key: 'superUserModeEnabled', value: checked })
|
||||||
}
|
}
|
||||||
@@ -554,25 +534,49 @@ export function General({ onOpenChange }: GeneralProps) {
|
|||||||
time.
|
time.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isTrainingEnabled && (
|
{(isSuperadmin || isPlaygroundEnabled || isTrainingEnabled) && (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex flex-col gap-[8px] border-t pt-[16px]'>
|
||||||
<Label htmlFor='training-controls'>Training controls</Label>
|
<p className='font-medium text-[12px] text-[var(--text-tertiary)]'>Developer Tools</p>
|
||||||
<Switch
|
<div className='flex flex-wrap gap-[8px]'>
|
||||||
id='training-controls'
|
{isSuperadmin && (
|
||||||
checked={settings?.showTrainingControls ?? false}
|
<Link href='/admin/impersonate' onClick={() => onOpenChange?.(false)}>
|
||||||
onCheckedChange={handleTrainingControlsChange}
|
<Button variant='active' size='sm'>
|
||||||
/>
|
Impersonate User
|
||||||
</div>
|
</Button>
|
||||||
)}
|
</Link>
|
||||||
|
)}
|
||||||
{!loadingSuperUser && isSuperUser && (
|
{isPlaygroundEnabled && (
|
||||||
<div className='flex items-center justify-between'>
|
<Link href='/playground' onClick={() => onOpenChange?.(false)}>
|
||||||
<Label htmlFor='super-user-mode'>Super admin mode</Label>
|
<Button variant='active' size='sm'>
|
||||||
<Switch
|
Component Playground
|
||||||
id='super-user-mode'
|
</Button>
|
||||||
checked={settings?.superUserModeEnabled ?? true}
|
</Link>
|
||||||
onCheckedChange={handleSuperUserModeToggle}
|
)}
|
||||||
/>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
|||||||
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
|
||||||
import {
|
import {
|
||||||
HelpModal,
|
HelpModal,
|
||||||
|
ImpersonationIndicator,
|
||||||
NavItemContextMenu,
|
NavItemContextMenu,
|
||||||
SearchModal,
|
SearchModal,
|
||||||
SettingsModal,
|
SettingsModal,
|
||||||
@@ -643,6 +644,13 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Impersonation Indicator */}
|
||||||
|
{sessionData?.session?.impersonatedBy && (
|
||||||
|
<ImpersonationIndicator
|
||||||
|
userName={sessionData?.user?.name || sessionData?.user?.email || 'Unknown'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Usage Indicator */}
|
{/* Usage Indicator */}
|
||||||
{isBillingEnabled && <UsageIndicator />}
|
{isBillingEnabled && <UsageIndicator />}
|
||||||
|
|
||||||
|
|||||||
57
apps/sim/hooks/queries/admin-status.ts
Normal file
57
apps/sim/hooks/queries/admin-status.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const logger = createLogger('AdminStatusQuery')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query key factories for admin status
|
||||||
|
*/
|
||||||
|
export const adminStatusKeys = {
|
||||||
|
all: ['adminStatus'] as const,
|
||||||
|
current: () => [...adminStatusKeys.all, 'current'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin status response type
|
||||||
|
*/
|
||||||
|
export interface AdminStatus {
|
||||||
|
hasAdminPrivileges: boolean
|
||||||
|
role: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch current user's admin status from API.
|
||||||
|
* Returns default non-admin state if user is not authenticated.
|
||||||
|
*/
|
||||||
|
async function fetchAdminStatus(): Promise<AdminStatus> {
|
||||||
|
const response = await fetch('/api/user/admin-status')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Not authenticated - return default state
|
||||||
|
return { hasAdminPrivileges: false, role: null }
|
||||||
|
}
|
||||||
|
logger.error('Failed to fetch admin status', { status: response.status })
|
||||||
|
throw new Error('Failed to fetch admin status')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return {
|
||||||
|
hasAdminPrivileges: data.hasAdminPrivileges,
|
||||||
|
role: data.role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch current user's admin status
|
||||||
|
*/
|
||||||
|
export function useAdminStatus(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminStatusKeys.current(),
|
||||||
|
queryFn: fetchAdminStatus,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes - role doesn't change often
|
||||||
|
placeholderData: keepPreviousData, // Show cached data immediately
|
||||||
|
retry: false, // Don't retry on 401
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useContext } from 'react'
|
|||||||
import { ssoClient } from '@better-auth/sso/client'
|
import { ssoClient } from '@better-auth/sso/client'
|
||||||
import { stripeClient } from '@better-auth/stripe/client'
|
import { stripeClient } from '@better-auth/stripe/client'
|
||||||
import {
|
import {
|
||||||
|
adminClient,
|
||||||
customSessionClient,
|
customSessionClient,
|
||||||
emailOTPClient,
|
emailOTPClient,
|
||||||
genericOAuthClient,
|
genericOAuthClient,
|
||||||
@@ -16,10 +17,14 @@ import { SessionContext, type SessionHookResult } from '@/app/_shell/providers/s
|
|||||||
|
|
||||||
export const client = createAuthClient({
|
export const client = createAuthClient({
|
||||||
baseURL: getBaseUrl(),
|
baseURL: getBaseUrl(),
|
||||||
|
fetchOptions: {
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
emailOTPClient(),
|
emailOTPClient(),
|
||||||
genericOAuthClient(),
|
genericOAuthClient(),
|
||||||
customSessionClient<typeof auth>(),
|
customSessionClient<typeof auth>(),
|
||||||
|
adminClient(),
|
||||||
...(isBillingEnabled
|
...(isBillingEnabled
|
||||||
? [
|
? [
|
||||||
stripeClient({
|
stripeClient({
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import * as schema from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { betterAuth } from 'better-auth'
|
import { betterAuth } from 'better-auth'
|
||||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||||
|
import { getSessionFromCtx } from 'better-auth/api'
|
||||||
import { nextCookies } from 'better-auth/next-js'
|
import { nextCookies } from 'better-auth/next-js'
|
||||||
import {
|
import {
|
||||||
|
admin,
|
||||||
createAuthMiddleware,
|
createAuthMiddleware,
|
||||||
customSession,
|
customSession,
|
||||||
emailOTP,
|
emailOTP,
|
||||||
@@ -519,6 +521,23 @@ export const auth = betterAuth({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impersonation authorization: only superadmin users can impersonate
|
||||||
|
if (ctx.path.startsWith('/admin/impersonate-user')) {
|
||||||
|
const session = await getSessionFromCtx(ctx)
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw new Error('You must be logged in to impersonate users.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await db.query.user.findFirst({
|
||||||
|
where: eq(schema.user.id, session.user.id),
|
||||||
|
columns: { role: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (currentUser?.role !== 'superadmin') {
|
||||||
|
throw new Error('Only superadmin users can impersonate other users.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -527,6 +546,11 @@ export const auth = betterAuth({
|
|||||||
oneTimeToken({
|
oneTimeToken({
|
||||||
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
|
expiresIn: 24 * 60 * 60, // 24 hours - Socket.IO handles connection persistence with heartbeats
|
||||||
}),
|
}),
|
||||||
|
admin({
|
||||||
|
impersonationSessionDuration: 60 * 60, // 1 hour in seconds
|
||||||
|
defaultRole: 'user',
|
||||||
|
adminRoles: ['superadmin'], // Only superadmins can use admin plugin features like impersonation
|
||||||
|
}),
|
||||||
customSession(async ({ user, session }) => ({
|
customSession(async ({ user, session }) => ({
|
||||||
user,
|
user,
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { and, eq, or } from 'drizzle-orm'
|
|||||||
export type CreatorPermissionLevel = 'member' | 'admin'
|
export type CreatorPermissionLevel = 'member' | 'admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies if a user is a super user.
|
* Verifies if a user has admin privileges (admin or superadmin role).
|
||||||
|
* Used for template approval, creator verification, etc.
|
||||||
*
|
*
|
||||||
* @param userId - The ID of the user to check
|
* @param userId - The ID of the user to check
|
||||||
* @returns Object with isSuperUser boolean
|
* @returns Object with hasAdminPrivileges boolean (true if admin or superadmin)
|
||||||
*/
|
*/
|
||||||
export async function verifySuperUser(userId: string): Promise<{ isSuperUser: boolean }> {
|
export async function verifyAdminPrivileges(
|
||||||
|
userId: string
|
||||||
|
): Promise<{ hasAdminPrivileges: boolean }> {
|
||||||
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
const [currentUser] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
|
||||||
return { isSuperUser: currentUser?.isSuperUser || false }
|
return { hasAdminPrivileges: currentUser?.role === 'admin' || currentUser?.role === 'superadmin' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
3
packages/db/migrations/0144_gifted_vanisher.sql
Normal file
3
packages/db/migrations/0144_gifted_vanisher.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_impersonated_by_user_id_fk" FOREIGN KEY ("impersonated_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
10318
packages/db/migrations/meta/0144_snapshot.json
Normal file
10318
packages/db/migrations/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1002,6 +1002,13 @@
|
|||||||
"when": 1768518143986,
|
"when": 1768518143986,
|
||||||
"tag": "0143_puzzling_xorn",
|
"tag": "0143_puzzling_xorn",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 144,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1768518787133,
|
||||||
|
"tag": "0144_gifted_vanisher",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const user = pgTable('user', {
|
|||||||
updatedAt: timestamp('updated_at').notNull(),
|
updatedAt: timestamp('updated_at').notNull(),
|
||||||
stripeCustomerId: text('stripe_customer_id'),
|
stripeCustomerId: text('stripe_customer_id'),
|
||||||
isSuperUser: boolean('is_super_user').notNull().default(false),
|
isSuperUser: boolean('is_super_user').notNull().default(false),
|
||||||
|
role: text('role'), // Used by Better Auth admin plugin for impersonation
|
||||||
})
|
})
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = pgTable(
|
||||||
@@ -57,6 +58,7 @@ export const session = pgTable(
|
|||||||
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
|
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
|
||||||
onDelete: 'set null',
|
onDelete: 'set null',
|
||||||
}),
|
}),
|
||||||
|
impersonatedBy: text('impersonated_by').references(() => user.id, { onDelete: 'cascade' }), // Admin user ID when impersonating
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userIdIdx: index('session_user_id_idx').on(table.userId),
|
userIdIdx: index('session_user_id_idx').on(table.userId),
|
||||||
|
|||||||
Reference in New Issue
Block a user