mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(impersonation): migrate to betterauth admin plugin for admin status, add impersonation
This commit is contained in:
@@ -22,15 +22,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
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 hasAdminPrivileges =
|
||||
currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to verify creator: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can verify creators' }, { status: 403 })
|
||||
if (!hasAdminPrivileges) {
|
||||
logger.warn(`[${requestId}] Non-admin user attempted to verify creator: ${id}`)
|
||||
return NextResponse.json({ error: 'Only admin users can verify creators' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if creator exists
|
||||
const existingCreator = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
@@ -42,7 +42,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update creator verified status to true
|
||||
await db
|
||||
.update(templateCreators)
|
||||
.set({ verified: true, updatedAt: new Date() })
|
||||
@@ -75,15 +74,15 @@ export async function DELETE(
|
||||
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 hasAdminPrivileges =
|
||||
currentUser[0]?.role === 'admin' || currentUser[0]?.role === 'superadmin'
|
||||
|
||||
if (!currentUser[0]?.isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to unverify creator: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can unverify creators' }, { status: 403 })
|
||||
if (!hasAdminPrivileges) {
|
||||
logger.warn(`[${requestId}] Non-admin user attempted to unverify creator: ${id}`)
|
||||
return NextResponse.json({ error: 'Only admin users can unverify creators' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if creator exists
|
||||
const existingCreator = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
@@ -95,7 +94,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Creator not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update creator verified status to false
|
||||
await db
|
||||
.update(templateCreators)
|
||||
.set({ verified: false, updatedAt: new Date() })
|
||||
|
||||
@@ -5,14 +5,14 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
import { verifyAdminPrivileges } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateApprovalAPI')
|
||||
|
||||
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 }> }) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -25,10 +25,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
|
||||
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||
if (!hasAdminPrivileges) {
|
||||
logger.warn(`[${requestId}] Non-admin user attempted to approve template: ${id}`)
|
||||
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)
|
||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
.set({ status: 'approved', updatedAt: new Date() })
|
||||
.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({
|
||||
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(
|
||||
_request: NextRequest,
|
||||
@@ -71,10 +71,10 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||
if (!hasAdminPrivileges) {
|
||||
logger.warn(`[${requestId}] Non-admin user attempted to reject template: ${id}`)
|
||||
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)
|
||||
@@ -88,7 +88,7 @@ export async function DELETE(
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.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({
|
||||
message: 'Template rejected successfully',
|
||||
|
||||
@@ -5,14 +5,14 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { verifySuperUser } from '@/lib/templates/permissions'
|
||||
import { verifyAdminPrivileges } from '@/lib/templates/permissions'
|
||||
|
||||
const logger = createLogger('TemplateRejectionAPI')
|
||||
|
||||
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 }> }) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -25,10 +25,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { isSuperUser } = await verifySuperUser(session.user.id)
|
||||
if (!isSuperUser) {
|
||||
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
|
||||
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
|
||||
const { hasAdminPrivileges } = await verifyAdminPrivileges(session.user.id)
|
||||
if (!hasAdminPrivileges) {
|
||||
logger.warn(`[${requestId}] Non-admin user attempted to reject template: ${id}`)
|
||||
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)
|
||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.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({
|
||||
message: 'Template rejected successfully',
|
||||
|
||||
@@ -23,13 +23,10 @@ const logger = createLogger('TemplatesAPI')
|
||||
|
||||
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 {
|
||||
return sanitizeCredentials(state)
|
||||
}
|
||||
|
||||
// Schema for creating a template
|
||||
const CreateTemplateSchema = z.object({
|
||||
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'),
|
||||
@@ -43,7 +40,6 @@ const CreateTemplateSchema = z.object({
|
||||
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
|
||||
})
|
||||
|
||||
// Schema for query parameters
|
||||
const QueryParamsSchema = z.object({
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
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)
|
||||
|
||||
// Check if user is a super user
|
||||
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 = []
|
||||
|
||||
// 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) {
|
||||
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 {
|
||||
// Apply status filter - only approved templates for non-super users
|
||||
if (params.status) {
|
||||
conditions.push(eq(templates.status, params.status))
|
||||
} else if (!isSuperUser || !params.includeAllStatuses) {
|
||||
// Non-super users and super users without includeAllStatuses flag see only approved templates
|
||||
conditions.push(eq(templates.status, 'approved'))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
conditions.push(
|
||||
@@ -104,10 +90,8 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Apply ordering, limit, and offset with star information
|
||||
const results = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
@@ -138,7 +122,6 @@ export async function GET(request: NextRequest) {
|
||||
.limit(params.limit)
|
||||
.offset(params.offset)
|
||||
|
||||
// Get total count for pagination
|
||||
const totalCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(templates)
|
||||
@@ -191,7 +174,6 @@ export async function POST(request: NextRequest) {
|
||||
workflowId: data.workflowId,
|
||||
})
|
||||
|
||||
// Verify the workflow exists and belongs to the user
|
||||
const workflowExists = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
@@ -218,7 +200,6 @@ export async function POST(request: NextRequest) {
|
||||
const templateId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
// Get the active deployment version for the workflow to copy its state
|
||||
const activeVersion = await db
|
||||
.select({
|
||||
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
|
||||
if (stateWithVariables && !stateWithVariables.variables) {
|
||||
// Fetch workflow variables if not in deployment version
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
@@ -259,10 +238,8 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract credential requirements before sanitizing
|
||||
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
|
||||
|
||||
// Sanitize the workflow state to remove all credential values
|
||||
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
|
||||
|
||||
const newTemplate = {
|
||||
|
||||
@@ -6,23 +6,26 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
|
||||
const logger = createLogger('SuperUserAPI')
|
||||
const logger = createLogger('AdminStatusAPI')
|
||||
|
||||
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) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
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 })
|
||||
}
|
||||
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
@@ -32,11 +35,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const role = currentUser[0].role
|
||||
return NextResponse.json({
|
||||
isSuperUser: currentUser[0].isSuperUser,
|
||||
hasAdminPrivileges: role === 'admin' || role === 'superadmin',
|
||||
role,
|
||||
})
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
* GET /api/v1/admin/users/:id - Get user details
|
||||
* GET /api/v1/admin/users/:id/billing - Get user billing info
|
||||
* 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:
|
||||
* GET /api/v1/admin/workspaces - List all workspaces
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface AdminUser {
|
||||
email: string
|
||||
emailVerified: boolean
|
||||
image: string | null
|
||||
role: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -116,6 +117,7 @@ export function toAdminUser(dbUser: DbUser): AdminUser {
|
||||
email: dbUser.email,
|
||||
emailVerified: dbUser.emailVerified,
|
||||
image: dbUser.image,
|
||||
role: dbUser.role,
|
||||
createdAt: dbUser.createdAt.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')
|
||||
}
|
||||
})
|
||||
@@ -38,6 +38,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/preview'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useAdminStatus } from '@/hooks/queries/admin-status'
|
||||
import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
|
||||
|
||||
const logger = createLogger('TemplateDetails')
|
||||
@@ -150,7 +151,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
|
||||
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 [isEditing, setIsEditing] = 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()
|
||||
}, [currentUserId])
|
||||
|
||||
@@ -650,7 +637,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{/* Approve/Reject buttons for super users */}
|
||||
{isSuperUser && template.status === 'pending' && (
|
||||
{hasAdminPrivileges && template.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant='active'
|
||||
@@ -974,7 +961,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
||||
<h3 className='font-sans font-semibold text-base text-foreground'>
|
||||
About the Creator
|
||||
</h3>
|
||||
{isSuperUser && template.creator && (
|
||||
{hasAdminPrivileges && template.creator && (
|
||||
<Button
|
||||
variant={template.creator.verified ? 'active' : 'default'}
|
||||
onClick={handleToggleVerification}
|
||||
|
||||
@@ -39,9 +39,9 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
redirect(`/workspace/${workspaceId}`)
|
||||
}
|
||||
|
||||
// Determine effective super user (DB flag AND UI mode enabled)
|
||||
// Determine effective super user (admin/superadmin role AND UI mode enabled)
|
||||
const currentUser = await db
|
||||
.select({ isSuperUser: user.isSuperUser })
|
||||
.select({ role: user.role })
|
||||
.from(user)
|
||||
.where(eq(user.id, session.user.id))
|
||||
.limit(1)
|
||||
@@ -51,7 +51,7 @@ export default async function TemplatesPage({ params }: TemplatesPageProps) {
|
||||
.where(eq(settings.userId, session.user.id))
|
||||
.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 effectiveSuperUser = isSuperUser && superUserModeEnabled
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
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 { 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'
|
||||
@@ -129,8 +130,8 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const isAuthDisabled = session?.user?.id === ANONYMOUS_USER_ID
|
||||
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
const { data: adminStatus, isLoading: loadingAdminStatus } = useAdminStatus(!!session?.user?.id)
|
||||
const hasAdminPrivileges = adminStatus?.hasAdminPrivileges ?? false
|
||||
|
||||
const [name, setName] = useState(profile?.name || '')
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
@@ -151,26 +152,6 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
}
|
||||
}, [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 {
|
||||
previewUrl: profilePictureUrl,
|
||||
fileInputRef: profilePictureInputRef,
|
||||
@@ -544,11 +525,11 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingSuperUser && isSuperUser && (
|
||||
{!loadingAdminStatus && hasAdminPrivileges && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='super-user-mode'>Super admin mode</Label>
|
||||
<Label htmlFor='admin-mode'>Admin mode</Label>
|
||||
<Switch
|
||||
id='super-user-mode'
|
||||
id='admin-mode'
|
||||
checked={settings?.superUserModeEnabled ?? true}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
/>
|
||||
|
||||
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 { stripeClient } from '@better-auth/stripe/client'
|
||||
import {
|
||||
adminClient,
|
||||
customSessionClient,
|
||||
emailOTPClient,
|
||||
genericOAuthClient,
|
||||
@@ -20,6 +21,7 @@ export const client = createAuthClient({
|
||||
emailOTPClient(),
|
||||
genericOAuthClient(),
|
||||
customSessionClient<typeof auth>(),
|
||||
adminClient(),
|
||||
...(isBillingEnabled
|
||||
? [
|
||||
stripeClient({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { nextCookies } from 'better-auth/next-js'
|
||||
import {
|
||||
admin,
|
||||
createAuthMiddleware,
|
||||
customSession,
|
||||
emailOTP,
|
||||
@@ -519,6 +520,23 @@ export const auth = betterAuth({
|
||||
}
|
||||
}
|
||||
|
||||
// Impersonation authorization: only superadmin users can impersonate
|
||||
if (ctx.path.startsWith('/admin/impersonate-user')) {
|
||||
const session = ctx.context.session
|
||||
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
|
||||
}),
|
||||
},
|
||||
@@ -527,6 +545,11 @@ export const auth = betterAuth({
|
||||
oneTimeToken({
|
||||
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 }) => ({
|
||||
user,
|
||||
session,
|
||||
|
||||
@@ -5,14 +5,17 @@ import { and, eq, or } from 'drizzle-orm'
|
||||
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
|
||||
* @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)
|
||||
return { isSuperUser: currentUser?.isSuperUser || false }
|
||||
return { hasAdminPrivileges: currentUser?.role === 'admin' || currentUser?.role === 'superadmin' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
3
packages/db/migrations/0142_solid_black_bolt.sql
Normal file
3
packages/db/migrations/0142_solid_black_bolt.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;
|
||||
10288
packages/db/migrations/meta/0142_snapshot.json
Normal file
10288
packages/db/migrations/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -988,6 +988,13 @@
|
||||
"when": 1768421319400,
|
||||
"tag": "0141_daffy_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1768518361916,
|
||||
"tag": "0142_solid_black_bolt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export const user = pgTable('user', {
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
isSuperUser: boolean('is_super_user').notNull().default(false),
|
||||
role: text('role'), // Used by Better Auth admin plugin for impersonation
|
||||
})
|
||||
|
||||
export const session = pgTable(
|
||||
@@ -57,6 +58,7 @@ export const session = pgTable(
|
||||
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
impersonatedBy: text('impersonated_by').references(() => user.id, { onDelete: 'cascade' }), // Admin user ID when impersonating
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('session_user_id_idx').on(table.userId),
|
||||
|
||||
Reference in New Issue
Block a user