mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(settings): settings components and behavior consolidation (#2100)
* fix(settings): settings components and behavior consolidation * ack PR comments
This commit is contained in:
@@ -244,16 +244,40 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const emailsToInvite = newEmails.filter((email: string) => !pendingEmails.includes(email))
|
||||
|
||||
if (emailsToInvite.length === 0) {
|
||||
const isSingleEmail = processedEmails.length === 1
|
||||
const existingMembersEmails = processedEmails.filter((email: string) =>
|
||||
existingEmails.includes(email)
|
||||
)
|
||||
const pendingInvitationEmails = processedEmails.filter((email: string) =>
|
||||
pendingEmails.includes(email)
|
||||
)
|
||||
|
||||
if (isSingleEmail) {
|
||||
if (existingMembersEmails.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Failed to send invitation. User is already a part of the organization.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (pendingInvitationEmails.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Failed to send invitation. A pending invitation already exists for this email.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'All emails are already members or have pending invitations',
|
||||
error: 'All emails are already members or have pending invitations.',
|
||||
details: {
|
||||
existingMembers: processedEmails.filter((email: string) =>
|
||||
existingEmails.includes(email)
|
||||
),
|
||||
pendingInvitations: processedEmails.filter((email: string) =>
|
||||
pendingEmails.includes(email)
|
||||
),
|
||||
existingMembers: existingMembersEmails,
|
||||
pendingInvitations: pendingInvitationEmails,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
|
||||
@@ -232,7 +232,11 @@ export function Account(_props: AccountProps) {
|
||||
<div className='flex flex-1 flex-col justify-center'>
|
||||
<h3 className='font-medium text-base'>{profile?.name || ''}</h3>
|
||||
<p className='font-normal text-muted-foreground text-sm'>{profile?.email || ''}</p>
|
||||
{uploadError && <p className='mt-1 text-destructive text-xs'>{uploadError}</p>}
|
||||
{uploadError && (
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -512,9 +512,9 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
autoFocus
|
||||
/>
|
||||
{createError && (
|
||||
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{createError}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +291,11 @@ export function CreatorProfile() {
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
{uploadError && <p className='text-destructive text-sm'>{uploadError}</p>}
|
||||
{uploadError && (
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{uploadError}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>PNG or JPEG (max 5MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,9 +415,9 @@ export function CreatorProfile() {
|
||||
{/* Error Message */}
|
||||
{saveError && (
|
||||
<div className='px-6 pb-2'>
|
||||
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{saveError}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, Plus, Search } from 'lucide-react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
@@ -84,7 +84,6 @@ export function CustomTools() {
|
||||
setShowDeleteDialog(false)
|
||||
|
||||
try {
|
||||
// Pass null workspaceId for user-scoped tools (legacy tools without workspaceId)
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId: tool.workspaceId ?? null,
|
||||
toolId: toolToDelete.id,
|
||||
@@ -105,7 +104,6 @@ export function CustomTools() {
|
||||
const handleToolSaved = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingTool(null)
|
||||
// React Query will automatically refetch via cache invalidation
|
||||
refetchTools()
|
||||
}
|
||||
|
||||
@@ -113,16 +111,6 @@ export function CustomTools() {
|
||||
<div className='relative flex h-full flex-col'>
|
||||
{/* Fixed Header with Search */}
|
||||
<div className='px-6 pt-4 pb-2'>
|
||||
{/* Error Alert - only show when modal is not open */}
|
||||
{error && !showAddForm && !editingTool && (
|
||||
<Alert variant='destructive' className='mb-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{error instanceof Error ? error.message : 'An error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
{isLoading ? (
|
||||
<Skeleton className='h-9 w-56 rounded-[8px]' />
|
||||
@@ -148,6 +136,12 @@ export function CustomTools() {
|
||||
<CustomToolSkeleton />
|
||||
<CustomToolSkeleton />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load tools'}
|
||||
</p>
|
||||
</div>
|
||||
) : tools.length === 0 && !showAddForm && !editingTool ? (
|
||||
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
|
||||
Click "Create Tool" below to get started
|
||||
|
||||
@@ -273,9 +273,9 @@ export function Files() {
|
||||
{/* Error message */}
|
||||
{uploadError && (
|
||||
<div className='px-6 pb-2'>
|
||||
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{uploadError}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -253,9 +253,9 @@ export function AddServerForm({
|
||||
<div className='space-y-1.5'>
|
||||
{/* Error message above buttons */}
|
||||
{testResult && !testResult.success && (
|
||||
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{testResult.error || testResult.message}
|
||||
</div>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Buttons row */}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { AlertCircle, Plus, Search } from 'lucide-react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { createMcpToolId } from '@/lib/mcp/utils'
|
||||
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown'
|
||||
@@ -24,7 +24,6 @@ export function MCP() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
// React Query hooks
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading: serversLoading,
|
||||
@@ -42,7 +41,7 @@ export function MCP() {
|
||||
transport: 'streamable-http',
|
||||
url: '',
|
||||
timeout: 30000,
|
||||
headers: {}, // Start with no headers
|
||||
headers: {},
|
||||
})
|
||||
|
||||
const [showEnvVars, setShowEnvVars] = useState(false)
|
||||
@@ -207,7 +206,6 @@ export function MCP() {
|
||||
|
||||
try {
|
||||
await deleteServerMutation.mutateAsync({ workspaceId, serverId })
|
||||
// TanStack Query mutations automatically invalidate and refetch tools
|
||||
|
||||
logger.info(`Removed MCP server: ${serverId}`)
|
||||
} catch (error) {
|
||||
@@ -264,27 +262,23 @@ export function MCP() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Alert */}
|
||||
{(toolsError || serversError) && (
|
||||
<Alert variant='destructive' className='mt-4'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{toolsError instanceof Error
|
||||
? toolsError.message
|
||||
: serversError instanceof Error
|
||||
? serversError.message
|
||||
: 'An error occurred'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
|
||||
<div className='space-y-2 pt-2 pb-6'>
|
||||
{/* Server List */}
|
||||
{serversLoading ? (
|
||||
{toolsError || serversError ? (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-2'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{toolsError instanceof Error
|
||||
? toolsError.message
|
||||
: serversError instanceof Error
|
||||
? serversError.message
|
||||
: 'Failed to load MCP servers'}
|
||||
</p>
|
||||
</div>
|
||||
) : serversLoading ? (
|
||||
<div className='space-y-2'>
|
||||
<McpServerSkeleton />
|
||||
<McpServerSkeleton />
|
||||
@@ -342,7 +336,6 @@ export function MCP() {
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{filteredServers.map((server: any) => {
|
||||
// Add defensive checks for server properties
|
||||
if (!server || !server.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button, Combobox } from '@/components/emcn'
|
||||
import { Alert, AlertDescription, Input, Label } from '@/components/ui'
|
||||
import { Button, Combobox, Input, Label } from '@/components/emcn'
|
||||
import { Alert, AlertDescription } from '@/components/ui'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
@@ -79,7 +79,6 @@ export function SSO() {
|
||||
const { data: subscriptionData } = useSubscriptionData()
|
||||
const activeOrganization = orgsData?.activeOrganization
|
||||
|
||||
// Determine if we should fetch SSO providers
|
||||
const userEmail = session?.user?.email
|
||||
const userId = session?.user?.id
|
||||
const userRole = getUserRole(activeOrganization, userEmail)
|
||||
@@ -89,14 +88,12 @@ export function SSO() {
|
||||
const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data)
|
||||
const hasEnterprisePlan = subscriptionStatus.isEnterprise
|
||||
|
||||
// Use React Query to fetch SSO providers
|
||||
const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders()
|
||||
|
||||
const providers = providersData?.providers || []
|
||||
const isSSOProviderOwner =
|
||||
!isBillingEnabled && userId ? providers.some((p: any) => p.userId === userId) : null
|
||||
|
||||
// Use mutation hook for configuring SSO
|
||||
const configureSSOMutation = useConfigureSSO()
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -331,12 +328,10 @@ export function SSO() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the mutation hook - this will automatically invalidate the cache
|
||||
await configureSSOMutation.mutateAsync(requestBody)
|
||||
|
||||
logger.info('SSO provider configured', { providerId: formData.providerId })
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
providerType: 'oidc',
|
||||
providerId: '',
|
||||
@@ -408,7 +403,6 @@ export function SSO() {
|
||||
|
||||
const handleReconfigure = (provider: SSOProvider) => {
|
||||
try {
|
||||
// Parse config based on provider type
|
||||
let clientId = ''
|
||||
let clientSecret = ''
|
||||
let scopes = 'openid,profile,email'
|
||||
@@ -480,14 +474,7 @@ export function SSO() {
|
||||
/>
|
||||
<div className='flex-1 overflow-y-auto px-6 pt-4 pb-4'>
|
||||
<div className='space-y-6'>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showStatus ? (
|
||||
// SSO Provider Status View
|
||||
<div className='space-y-4'>
|
||||
{providers.map((provider: SSOProvider) => (
|
||||
<div key={provider.id} className='rounded-[8px] bg-muted/30 p-4'>
|
||||
@@ -558,7 +545,6 @@ export function SSO() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// SSO Configuration Form
|
||||
<>
|
||||
{hasProviders && (
|
||||
<div className='mb-4'>
|
||||
@@ -631,9 +617,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.providerId.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.providerId.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.providerId.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Select a pre-configured provider ID from the trusted providers list
|
||||
@@ -662,9 +648,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.issuerUrl.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.issuerUrl.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.issuerUrl.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</div>
|
||||
@@ -691,9 +677,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.domain.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.domain.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.domain.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -722,9 +708,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.clientId.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.clientId.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.clientId.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -775,9 +761,9 @@ export function SSO() {
|
||||
</Button>
|
||||
</div>
|
||||
{showErrors && errors.clientSecret.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.clientSecret.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.clientSecret.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -800,9 +786,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.scopes.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.scopes.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.scopes.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Comma-separated list of OIDC scopes to request
|
||||
@@ -830,9 +816,9 @@ export function SSO() {
|
||||
)}
|
||||
/>
|
||||
{showErrors && errors.entryPoint.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.entryPoint.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.entryPoint.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</div>
|
||||
@@ -856,9 +842,9 @@ export function SSO() {
|
||||
rows={4}
|
||||
/>
|
||||
{showErrors && errors.cert.length > 0 && (
|
||||
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
|
||||
<p>{errors.cert.join(' ')}</p>
|
||||
</div>
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{errors.cert.join(' ')}
|
||||
</p>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs' />
|
||||
</div>
|
||||
@@ -964,6 +950,12 @@ export function SSO() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='h-9 w-full'
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { CheckCircle, ChevronDown } from 'lucide-react'
|
||||
import { Button, Input, Label } from '@/components/emcn'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -27,28 +33,19 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex rounded-[12px] border border-input bg-background', className)}
|
||||
>
|
||||
{permissionOptions.map((option, index) => (
|
||||
<button
|
||||
<div className={cn('inline-flex gap-[2px]', className)}>
|
||||
{permissionOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type='button'
|
||||
variant={value === option.value ? 'active' : 'ghost'}
|
||||
onClick={() => !disabled && onChange(option.value)}
|
||||
disabled={disabled}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 font-medium text-xs transition-colors focus:outline-none',
|
||||
'first:rounded-l-[11px] last:rounded-r-[11px]',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
value === option.value
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
|
||||
index > 0 && 'border-input border-l'
|
||||
)}
|
||||
className='h-[22px] min-w-[38px] px-[6px] py-0 text-[11px]'
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -71,12 +68,8 @@ interface MemberInvitationCardProps {
|
||||
inviteSuccess: boolean
|
||||
availableSeats?: number
|
||||
maxSeats?: number
|
||||
}
|
||||
|
||||
function ButtonSkeleton() {
|
||||
return (
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-muted border-t-primary' />
|
||||
)
|
||||
invitationError?: Error | null
|
||||
isLoadingWorkspaces?: boolean
|
||||
}
|
||||
|
||||
export function MemberInvitationCard({
|
||||
@@ -93,12 +86,13 @@ export function MemberInvitationCard({
|
||||
inviteSuccess,
|
||||
availableSeats = 0,
|
||||
maxSeats = 0,
|
||||
invitationError = null,
|
||||
isLoadingWorkspaces = false,
|
||||
}: MemberInvitationCardProps) {
|
||||
const selectedCount = selectedWorkspaces.length
|
||||
const hasAvailableSeats = availableSeats > 0
|
||||
const [emailError, setEmailError] = useState<string>('')
|
||||
|
||||
// Email validation function using existing lib
|
||||
const validateEmailInput = (email: string) => {
|
||||
if (!email.trim()) {
|
||||
setEmailError('')
|
||||
@@ -116,14 +110,12 @@ export function MemberInvitationCard({
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInviteEmail(value)
|
||||
// Clear error when user starts typing again
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteClick = () => {
|
||||
// Validate email before proceeding
|
||||
if (inviteEmail.trim()) {
|
||||
validateEmailInput(inviteEmail)
|
||||
const validation = quickValidateEmail(inviteEmail.trim())
|
||||
@@ -132,7 +124,6 @@ export function MemberInvitationCard({
|
||||
}
|
||||
}
|
||||
|
||||
// If validation passes or email is empty, proceed with original invite
|
||||
onInviteMember()
|
||||
}
|
||||
|
||||
@@ -163,114 +154,118 @@ export function MemberInvitationCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
setShowWorkspaceInvite(!showWorkspaceInvite)
|
||||
if (!showWorkspaceInvite) {
|
||||
<Popover
|
||||
open={showWorkspaceInvite}
|
||||
onOpenChange={(open) => {
|
||||
setShowWorkspaceInvite(open)
|
||||
if (open) {
|
||||
onLoadUserWorkspaces()
|
||||
}
|
||||
}}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
showWorkspaceInvite && 'rotate-180'
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className='min-w-[110px]'
|
||||
>
|
||||
<span className='flex-1 text-left'>
|
||||
Workspaces
|
||||
{selectedCount > 0 && ` (${selectedCount})`}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
showWorkspaceInvite && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
maxHeight={320}
|
||||
sideOffset={4}
|
||||
className='w-[240px] border border-[var(--border-muted)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
style={{ minWidth: '240px', maxWidth: '240px' }}
|
||||
>
|
||||
{isLoadingWorkspaces ? (
|
||||
<div className='px-[6px] py-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>Loading...</p>
|
||||
</div>
|
||||
) : userWorkspaces.length === 0 ? (
|
||||
<div className='px-[6px] py-[16px] text-center'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>No workspaces available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div key={workspace.id} className='flex flex-col gap-[4px]'>
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
onWorkspaceToggle(workspace.id, '')
|
||||
} else {
|
||||
onWorkspaceToggle(workspace.id, 'read')
|
||||
}
|
||||
}}
|
||||
active={isSelected}
|
||||
disabled={isInviting}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={isInviting}
|
||||
className='pointer-events-none'
|
||||
/>
|
||||
<span className='flex-1 truncate'>{workspace.name}</span>
|
||||
</PopoverItem>
|
||||
{isSelected && (
|
||||
<div className='ml-[31px] flex items-center gap-[6px] pb-[4px]'>
|
||||
<span className='text-[11px] text-[var(--text-tertiary)]'>Access:</span>
|
||||
<PermissionSelector
|
||||
value={
|
||||
(['read', 'write', 'admin'].includes(
|
||||
selectedWorkspace?.permission ?? ''
|
||||
)
|
||||
? selectedWorkspace?.permission
|
||||
: 'read') as PermissionType
|
||||
}
|
||||
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
Workspaces
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant='secondary'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
{isInviting ? <ButtonSkeleton /> : hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
{isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Workspace selection - collapsible */}
|
||||
{showWorkspaceInvite && (
|
||||
<div className='space-y-3 rounded-md border border-[var(--border-muted)] bg-[var(--surface-2)] p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h5 className='font-medium text-xs'>Workspace Access</h5>
|
||||
<span className='text-[11px] text-muted-foreground'>(Optional)</span>
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<span className='text-muted-foreground text-xs'>{selectedCount} selected</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userWorkspaces.length === 0 ? (
|
||||
<div className='py-4 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>No workspaces available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-h-48 space-y-1.5 overflow-y-auto'>
|
||||
{userWorkspaces.map((workspace) => {
|
||||
const isSelected = selectedWorkspaces.some((w) => w.workspaceId === workspace.id)
|
||||
const selectedWorkspace = selectedWorkspaces.find(
|
||||
(w) => w.workspaceId === workspace.id
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className='flex items-center justify-between gap-2 rounded-md px-2 py-1.5 hover:bg-[var(--surface-3)]'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id={`workspace-${workspace.id}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onWorkspaceToggle(workspace.id, 'read')
|
||||
} else {
|
||||
onWorkspaceToggle(workspace.id, '')
|
||||
}
|
||||
}}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`workspace-${workspace.id}`}
|
||||
className='cursor-pointer text-xs'
|
||||
>
|
||||
{workspace.name}
|
||||
</Label>
|
||||
{workspace.isOwner && (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='h-[1.125rem] rounded-[6px] px-2 py-0 text-[10px]'
|
||||
>
|
||||
Owner
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<PermissionSelector
|
||||
value={
|
||||
(['read', 'write', 'admin'].includes(
|
||||
selectedWorkspace?.permission ?? ''
|
||||
)
|
||||
? selectedWorkspace?.permission
|
||||
: 'read') as PermissionType
|
||||
}
|
||||
onChange={(permission) => onWorkspaceToggle(workspace.id, permission)}
|
||||
disabled={isInviting}
|
||||
className='w-auto'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Invitation error - inline */}
|
||||
{invitationError && (
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{invitationError instanceof Error && invitationError.message
|
||||
? invitationError.message
|
||||
: String(invitationError)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
} from '@/components/emcn'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
@@ -87,22 +86,22 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Team Workspace
|
||||
</Button>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{error && (
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<Button
|
||||
onClick={onCreateOrganization}
|
||||
disabled={!orgName || !orgSlug || isCreatingOrg}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
{isCreatingOrg && <RefreshCw className='mr-2 h-4 w-4 animate-spin' />}
|
||||
Create Team Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,13 +116,6 @@ export function NoOrganizationView({
|
||||
</ModalHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{error && (
|
||||
<Alert variant='destructive' className='rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-name' className='font-medium text-sm'>
|
||||
Organization Name
|
||||
@@ -153,6 +145,12 @@ export function NoOrganizationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -188,7 +186,6 @@ export function NoOrganizationView({
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Open the subscription tab
|
||||
const event = new CustomEvent('open-settings', {
|
||||
detail: { tab: 'subscription' },
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ interface RemoveMemberDialogProps {
|
||||
memberName: string
|
||||
shouldReduceSeats: boolean
|
||||
isSelfRemoval?: boolean
|
||||
error?: Error | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
|
||||
onConfirmRemove: (shouldReduceSeats: boolean) => Promise<void>
|
||||
@@ -23,6 +24,7 @@ export function RemoveMemberDialog({
|
||||
open,
|
||||
memberName,
|
||||
shouldReduceSeats,
|
||||
error,
|
||||
onOpenChange,
|
||||
onShouldReduceSeatsChange,
|
||||
onConfirmRemove,
|
||||
@@ -64,6 +66,14 @@ export function RemoveMemberDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className='pb-2'>
|
||||
<p className='text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error && error.message ? error.message : String(error)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModalFooter>
|
||||
<Button variant='outline' onClick={onCancel} className='h-[32px] px-[12px]'>
|
||||
Cancel
|
||||
|
||||
@@ -23,6 +23,7 @@ interface TeamSeatsProps {
|
||||
currentSeats?: number
|
||||
initialSeats?: number
|
||||
isLoading: boolean
|
||||
error?: Error | null
|
||||
onConfirm: (seats: number) => Promise<void>
|
||||
confirmButtonText: string
|
||||
showCostBreakdown?: boolean
|
||||
@@ -37,6 +38,7 @@ export function TeamSeats({
|
||||
currentSeats,
|
||||
initialSeats = 1,
|
||||
isLoading,
|
||||
error,
|
||||
onConfirm,
|
||||
confirmButtonText,
|
||||
showCostBreakdown = false,
|
||||
@@ -103,6 +105,12 @@ export function TeamSeats({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className='mt-3 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error && error.message ? error.message : String(error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useRef } from 'react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription/helpers'
|
||||
@@ -53,13 +51,11 @@ export function TeamUsage({ hasAdminAccess }: TeamUsageProps) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<p className='text-center text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
{error instanceof Error ? error.message : 'Failed to load billing data'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, AlertDescription, AlertTitle, Skeleton } from '@/components/ui'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
generateSlug,
|
||||
getUsedSeats,
|
||||
getUserRole,
|
||||
isAdminOrOwner,
|
||||
type Workspace,
|
||||
} from '@/lib/organization'
|
||||
import { getSubscriptionStatus } from '@/lib/subscription'
|
||||
import { generateSlug, getUsedSeats, getUserRole, isAdminOrOwner } from '@/lib/organization'
|
||||
import {
|
||||
MemberInvitationCard,
|
||||
NoOrganizationView,
|
||||
@@ -23,7 +15,7 @@ import {
|
||||
TeamSeatsOverview,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/team-management/components'
|
||||
import {
|
||||
organizationKeys,
|
||||
useCreateOrganization,
|
||||
useInviteMember,
|
||||
useOrganization,
|
||||
useOrganizationSubscription,
|
||||
@@ -32,25 +24,21 @@ import {
|
||||
useUpdateSeats,
|
||||
} from '@/hooks/queries/organization'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import { useAdminWorkspaces } from '@/hooks/queries/workspace'
|
||||
|
||||
const logger = createLogger('TeamManagement')
|
||||
|
||||
export function TeamManagement() {
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch organizations and billing data using React Query
|
||||
const { data: organizationsData } = useOrganizations()
|
||||
const activeOrganization = organizationsData?.activeOrganization
|
||||
const billingData = organizationsData?.billingData?.data
|
||||
const hasTeamPlan = billingData?.isTeam ?? false
|
||||
const hasEnterprisePlan = billingData?.isEnterprise ?? false
|
||||
|
||||
// Fetch user subscription data
|
||||
const { data: userSubscriptionData } = useSubscriptionData()
|
||||
const subscriptionStatus = getSubscriptionStatus(userSubscriptionData?.data)
|
||||
|
||||
// Use React Query hooks for data fetching and mutations
|
||||
const {
|
||||
data: organization,
|
||||
isLoading,
|
||||
@@ -66,8 +54,8 @@ export function TeamManagement() {
|
||||
const inviteMutation = useInviteMember()
|
||||
const removeMemberMutation = useRemoveMember()
|
||||
const updateSeatsMutation = useUpdateSeats()
|
||||
const createOrgMutation = useCreateOrganization()
|
||||
|
||||
// Track invitation success for UI feedback
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false)
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
@@ -88,70 +76,15 @@ export function TeamManagement() {
|
||||
const [isAddSeatDialogOpen, setIsAddSeatDialogOpen] = useState(false)
|
||||
const [newSeatCount, setNewSeatCount] = useState(1)
|
||||
const [isUpdatingSeats, setIsUpdatingSeats] = useState(false)
|
||||
const [isCreatingOrg, setIsCreatingOrg] = useState(false)
|
||||
const [userWorkspaces, setUserWorkspaces] = useState<Workspace[]>([])
|
||||
|
||||
// Compute user role and permissions
|
||||
const { data: adminWorkspaces = [], isLoading: isLoadingWorkspaces } = useAdminWorkspaces(
|
||||
session?.user?.id
|
||||
)
|
||||
|
||||
const userRole = getUserRole(organization, session?.user?.email)
|
||||
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
|
||||
const usedSeats = getUsedSeats(organization)
|
||||
|
||||
// Load user workspaces
|
||||
const loadUserWorkspaces = useCallback(async (userId?: string) => {
|
||||
try {
|
||||
const workspacesResponse = await fetch('/api/workspaces')
|
||||
if (!workspacesResponse.ok) {
|
||||
logger.error('Failed to fetch workspaces')
|
||||
return
|
||||
}
|
||||
|
||||
const workspacesData = await workspacesResponse.json()
|
||||
const allUserWorkspaces = workspacesData.workspaces || []
|
||||
|
||||
// Filter to only show workspaces where user has admin permissions
|
||||
const adminWorkspaces = []
|
||||
|
||||
for (const workspace of allUserWorkspaces) {
|
||||
try {
|
||||
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
|
||||
if (permissionResponse.ok) {
|
||||
const permissionData = await permissionResponse.json()
|
||||
|
||||
let hasAdminAccess = false
|
||||
|
||||
if (userId && permissionData.users) {
|
||||
const currentUserPermission = permissionData.users.find(
|
||||
(user: any) => user.id === userId || user.userId === userId
|
||||
)
|
||||
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
||||
}
|
||||
|
||||
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
||||
|
||||
if (hasAdminAccess || isOwner) {
|
||||
adminWorkspaces.push({
|
||||
...workspace,
|
||||
isOwner: isOwner,
|
||||
canInvite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check permissions for workspace ${workspace.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setUserWorkspaces(adminWorkspaces)
|
||||
logger.info('Loaded admin workspaces for invitation', {
|
||||
total: allUserWorkspaces.length,
|
||||
adminWorkspaces: adminWorkspaces.length,
|
||||
userId: userId || 'not provided',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to load workspaces:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if ((hasTeamPlan || hasEnterprisePlan) && session?.user?.name && !orgName) {
|
||||
const defaultName = `${session.user.name}'s Team`
|
||||
@@ -160,13 +93,6 @@ export function TeamManagement() {
|
||||
}
|
||||
}, [hasTeamPlan, hasEnterprisePlan, session?.user?.name, orgName])
|
||||
|
||||
const activeOrgId = activeOrganization?.id
|
||||
useEffect(() => {
|
||||
if (session?.user?.id && activeOrgId && adminOrOwner) {
|
||||
loadUserWorkspaces(session.user.id)
|
||||
}
|
||||
}, [session?.user?.id, activeOrgId, adminOrOwner, loadUserWorkspaces])
|
||||
|
||||
const handleOrgNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newName = e.target.value
|
||||
setOrgName(newName)
|
||||
@@ -176,58 +102,35 @@ export function TeamManagement() {
|
||||
const handleCreateOrganization = useCallback(async () => {
|
||||
if (!session?.user || !orgName.trim()) return
|
||||
|
||||
setIsCreatingOrg(true)
|
||||
try {
|
||||
const response = await fetch('/api/organizations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: orgName.trim(),
|
||||
slug: orgSlug.trim(),
|
||||
}),
|
||||
await createOrgMutation.mutateAsync({
|
||||
name: orgName.trim(),
|
||||
slug: orgSlug.trim(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create organization: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success || !result.organizationId) {
|
||||
throw new Error('Failed to create organization')
|
||||
}
|
||||
|
||||
// Refresh organization data using React Query
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.lists() })
|
||||
|
||||
setCreateOrgDialogOpen(false)
|
||||
setOrgName('')
|
||||
setOrgSlug('')
|
||||
} catch (error) {
|
||||
logger.error('Failed to create organization', error)
|
||||
} finally {
|
||||
setIsCreatingOrg(false)
|
||||
}
|
||||
}, [session?.user?.id, orgName, orgSlug, queryClient])
|
||||
}, [orgName, orgSlug, createOrgMutation])
|
||||
|
||||
const handleInviteMember = useCallback(async () => {
|
||||
if (!session?.user || !activeOrgId || !inviteEmail.trim()) return
|
||||
if (!session?.user || !activeOrganization?.id || !inviteEmail.trim()) return
|
||||
|
||||
try {
|
||||
// Map selectedWorkspaces to the format expected by the API
|
||||
const workspaceInvitations =
|
||||
selectedWorkspaces.length > 0
|
||||
? selectedWorkspaces.map((w) => ({
|
||||
id: w.workspaceId,
|
||||
name: userWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
|
||||
name: adminWorkspaces.find((uw) => uw.id === w.workspaceId)?.name || '',
|
||||
}))
|
||||
: undefined
|
||||
|
||||
await inviteMutation.mutateAsync({
|
||||
email: inviteEmail.trim(),
|
||||
orgId: activeOrgId,
|
||||
orgId: activeOrganization.id,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
@@ -244,10 +147,10 @@ export function TeamManagement() {
|
||||
}
|
||||
}, [
|
||||
session?.user?.id,
|
||||
activeOrgId,
|
||||
activeOrganization?.id,
|
||||
inviteEmail,
|
||||
selectedWorkspaces,
|
||||
userWorkspaces,
|
||||
adminWorkspaces,
|
||||
inviteMutation,
|
||||
])
|
||||
|
||||
@@ -269,9 +172,8 @@ export function TeamManagement() {
|
||||
|
||||
const handleRemoveMember = useCallback(
|
||||
async (member: any) => {
|
||||
if (!session?.user || !activeOrgId) return
|
||||
if (!session?.user || !activeOrganization?.id) return
|
||||
|
||||
// The member object should have user.id - that's the actual user ID
|
||||
if (!member.user?.id) {
|
||||
logger.error('Member object missing user ID', { member })
|
||||
return
|
||||
@@ -290,18 +192,18 @@ export function TeamManagement() {
|
||||
isSelfRemoval: isLeavingSelf,
|
||||
})
|
||||
},
|
||||
[session?.user, activeOrgId]
|
||||
[session?.user, activeOrganization?.id]
|
||||
)
|
||||
|
||||
const confirmRemoveMember = useCallback(
|
||||
async (shouldReduceSeats = false) => {
|
||||
const { memberId } = removeMemberDialog
|
||||
if (!session?.user || !activeOrgId || !memberId) return
|
||||
if (!session?.user || !activeOrganization?.id || !memberId) return
|
||||
|
||||
try {
|
||||
await removeMemberMutation.mutateAsync({
|
||||
memberId,
|
||||
orgId: activeOrgId,
|
||||
orgId: activeOrganization?.id,
|
||||
shouldReduceSeats,
|
||||
})
|
||||
setRemoveMemberDialog({
|
||||
@@ -314,11 +216,11 @@ export function TeamManagement() {
|
||||
logger.error('Failed to remove member', error)
|
||||
}
|
||||
},
|
||||
[removeMemberDialog.memberId, session?.user?.id, activeOrgId, removeMemberMutation]
|
||||
[removeMemberDialog.memberId, session?.user?.id, activeOrganization?.id, removeMemberMutation]
|
||||
)
|
||||
|
||||
const handleReduceSeats = useCallback(async () => {
|
||||
if (!session?.user || !activeOrgId || !subscriptionData) return
|
||||
if (!session?.user || !activeOrganization?.id || !subscriptionData) return
|
||||
if (checkEnterprisePlan(subscriptionData)) return
|
||||
|
||||
const currentSeats = subscriptionData.seats || 0
|
||||
@@ -329,14 +231,14 @@ export function TeamManagement() {
|
||||
|
||||
try {
|
||||
await updateSeatsMutation.mutateAsync({
|
||||
orgId: activeOrgId,
|
||||
orgId: activeOrganization?.id,
|
||||
seats: currentSeats - 1,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to reduce seats', error)
|
||||
}
|
||||
}, [session?.user?.id, activeOrgId, subscriptionData, usedSeats, updateSeatsMutation])
|
||||
}, [session?.user?.id, activeOrganization?.id, subscriptionData, usedSeats, updateSeatsMutation])
|
||||
|
||||
const handleAddSeatDialog = useCallback(() => {
|
||||
if (subscriptionData) {
|
||||
@@ -347,14 +249,14 @@ export function TeamManagement() {
|
||||
|
||||
const confirmAddSeats = useCallback(
|
||||
async (selectedSeats?: number) => {
|
||||
if (!subscriptionData || !activeOrgId) return
|
||||
if (!subscriptionData || !activeOrganization?.id) return
|
||||
|
||||
const seatsToUse = selectedSeats || newSeatCount
|
||||
setIsUpdatingSeats(true)
|
||||
|
||||
try {
|
||||
await updateSeatsMutation.mutateAsync({
|
||||
orgId: activeOrgId,
|
||||
orgId: activeOrganization?.id,
|
||||
seats: seatsToUse,
|
||||
subscriptionId: subscriptionData.id,
|
||||
})
|
||||
@@ -365,19 +267,18 @@ export function TeamManagement() {
|
||||
setIsUpdatingSeats(false)
|
||||
}
|
||||
},
|
||||
[subscriptionData, activeOrgId, newSeatCount, updateSeatsMutation]
|
||||
[subscriptionData, activeOrganization?.id, newSeatCount, updateSeatsMutation]
|
||||
)
|
||||
|
||||
const confirmTeamUpgrade = useCallback(
|
||||
async (seats: number) => {
|
||||
if (!session?.user || !activeOrgId) return
|
||||
logger.info('Team upgrade requested', { seats, organizationId: activeOrgId })
|
||||
if (!session?.user || !activeOrganization?.id) return
|
||||
logger.info('Team upgrade requested', { seats, organizationId: activeOrganization?.id })
|
||||
alert(`Team upgrade to ${seats} seats - integration needed`)
|
||||
},
|
||||
[session?.user?.id, activeOrgId]
|
||||
[session?.user?.id, activeOrganization?.id]
|
||||
)
|
||||
|
||||
// Combine errors from different sources
|
||||
const queryError = orgError || subscriptionError
|
||||
const errorMessage = queryError instanceof Error ? queryError.message : null
|
||||
const displayOrganization = organization || activeOrganization
|
||||
@@ -459,7 +360,7 @@ export function TeamManagement() {
|
||||
setOrgSlug={setOrgSlug}
|
||||
onOrgNameChange={handleOrgNameChange}
|
||||
onCreateOrganization={handleCreateOrganization}
|
||||
isCreatingOrg={isCreatingOrg}
|
||||
isCreatingOrg={createOrgMutation.isPending}
|
||||
error={errorMessage}
|
||||
createOrgDialogOpen={createOrgDialogOpen}
|
||||
setCreateOrgDialogOpen={setCreateOrgDialogOpen}
|
||||
@@ -470,51 +371,6 @@ export function TeamManagement() {
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='flex flex-1 flex-col overflow-y-auto px-6 pt-4 pb-4'>
|
||||
{queryError && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
{queryError instanceof Error
|
||||
? queryError.message
|
||||
: 'Failed to load organization data'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Mutation errors */}
|
||||
{inviteMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Invitation Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{inviteMutation.error instanceof Error
|
||||
? inviteMutation.error.message
|
||||
: 'Failed to invite member'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{removeMemberMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Remove Member Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{removeMemberMutation.error instanceof Error
|
||||
? removeMemberMutation.error.message
|
||||
: 'Failed to remove member'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{updateSeatsMutation.error && (
|
||||
<Alert variant='destructive' className='mb-4 rounded-[8px]'>
|
||||
<AlertTitle>Update Seats Failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
{updateSeatsMutation.error instanceof Error
|
||||
? updateSeatsMutation.error.message
|
||||
: 'Failed to update seats'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seats Overview - Full Width */}
|
||||
{adminOrOwner && (
|
||||
<div className='mb-4'>
|
||||
@@ -530,16 +386,6 @@ export function TeamManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content: Team Members */}
|
||||
<div className='mb-4'>
|
||||
<TeamMembers
|
||||
organization={displayOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action: Invite New Members */}
|
||||
{adminOrOwner && (
|
||||
<div className='mb-4'>
|
||||
@@ -550,17 +396,29 @@ export function TeamManagement() {
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={userWorkspaces}
|
||||
userWorkspaces={adminWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={() => loadUserWorkspaces(session?.user?.id)}
|
||||
onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, (subscriptionData?.seats || 0) - usedSeats.used)}
|
||||
maxSeats={subscriptionData?.seats || 0}
|
||||
invitationError={inviteMutation.error}
|
||||
isLoadingWorkspaces={isLoadingWorkspaces}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content: Team Members */}
|
||||
<div className='mb-4'>
|
||||
<TeamMembers
|
||||
organization={displayOrganization}
|
||||
currentUserEmail={session?.user?.email ?? ''}
|
||||
isAdminOrOwner={adminOrOwner}
|
||||
onRemoveMember={handleRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Info - Subtle and collapsed */}
|
||||
<div className='space-y-3'>
|
||||
{/* Single Organization Notice */}
|
||||
@@ -575,7 +433,7 @@ export function TeamManagement() {
|
||||
|
||||
{/* Team Information */}
|
||||
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between rounded-lg p-3 font-medium text-sm hover:bg-[var(--surface-4)] group-open:rounded-b-none'>
|
||||
<span>Team Information</span>
|
||||
<svg
|
||||
className='h-4 w-4 transition-transform group-open:rotate-180'
|
||||
@@ -610,7 +468,7 @@ export function TeamManagement() {
|
||||
{/* Team Billing Information (only show for Team Plan, not Enterprise) */}
|
||||
{hasTeamPlan && !hasEnterprisePlan && (
|
||||
<details className='group rounded-lg border border-[var(--border-muted)] bg-[var(--surface-3)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between p-3 font-medium text-sm hover:bg-[var(--surface-4)]'>
|
||||
<summary className='flex cursor-pointer items-center justify-between rounded-lg p-3 font-medium text-sm hover:bg-[var(--surface-4)] group-open:rounded-b-none'>
|
||||
<span>Billing Information</span>
|
||||
<svg
|
||||
className='h-4 w-4 transition-transform group-open:rotate-180'
|
||||
@@ -656,6 +514,7 @@ export function TeamManagement() {
|
||||
memberName={removeMemberDialog.memberName}
|
||||
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
|
||||
isSelfRemoval={removeMemberDialog.isSelfRemoval}
|
||||
error={removeMemberMutation.error}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
|
||||
}}
|
||||
@@ -685,6 +544,7 @@ export function TeamManagement() {
|
||||
currentSeats={subscriptionData?.seats || 1}
|
||||
initialSeats={newSeatCount}
|
||||
isLoading={isUpdatingSeats}
|
||||
error={updateSeatsMutation.error}
|
||||
onConfirm={async (selectedSeats: number) => {
|
||||
setNewSeatCount(selectedSeats)
|
||||
await confirmAddSeats(selectedSeats)
|
||||
|
||||
@@ -274,7 +274,7 @@ export function useInviteMember() {
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to invite member')
|
||||
throw new Error(error.error || error.message || 'Failed to invite member')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
|
||||
@@ -9,6 +9,8 @@ export const workspaceKeys = {
|
||||
detail: (id: string) => [...workspaceKeys.details(), id] as const,
|
||||
settings: (id: string) => [...workspaceKeys.detail(id), 'settings'] as const,
|
||||
permissions: (id: string) => [...workspaceKeys.detail(id), 'permissions'] as const,
|
||||
adminLists: () => [...workspaceKeys.all, 'adminList'] as const,
|
||||
adminList: (userId: string | undefined) => [...workspaceKeys.adminLists(), userId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,3 +84,91 @@ export function useUpdateWorkspaceSettings() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace type returned by admin workspaces query
|
||||
*/
|
||||
export interface AdminWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
isOwner: boolean
|
||||
ownerId?: string
|
||||
canInvite: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workspaces where user has admin access
|
||||
*/
|
||||
async function fetchAdminWorkspaces(userId: string | undefined): Promise<AdminWorkspace[]> {
|
||||
if (!userId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const workspacesResponse = await fetch('/api/workspaces')
|
||||
if (!workspacesResponse.ok) {
|
||||
throw new Error('Failed to fetch workspaces')
|
||||
}
|
||||
|
||||
const workspacesData = await workspacesResponse.json()
|
||||
const allUserWorkspaces = workspacesData.workspaces || []
|
||||
|
||||
const permissionPromises = allUserWorkspaces.map(
|
||||
async (workspace: { id: string; name: string; isOwner?: boolean; ownerId?: string }) => {
|
||||
try {
|
||||
const permissionResponse = await fetch(`/api/workspaces/${workspace.id}/permissions`)
|
||||
if (!permissionResponse.ok) {
|
||||
return null
|
||||
}
|
||||
const permissionData = await permissionResponse.json()
|
||||
return { workspace, permissionData }
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const results = await Promise.all(permissionPromises)
|
||||
|
||||
const adminWorkspaces: AdminWorkspace[] = []
|
||||
for (const result of results) {
|
||||
if (!result) continue
|
||||
|
||||
const { workspace, permissionData } = result
|
||||
let hasAdminAccess = false
|
||||
|
||||
if (permissionData.users) {
|
||||
const currentUserPermission = permissionData.users.find(
|
||||
(user: { id: string; userId?: string; permissionType: string }) =>
|
||||
user.id === userId || user.userId === userId
|
||||
)
|
||||
hasAdminAccess = currentUserPermission?.permissionType === 'admin'
|
||||
}
|
||||
|
||||
const isOwner = workspace.isOwner || workspace.ownerId === userId
|
||||
|
||||
if (hasAdminAccess || isOwner) {
|
||||
adminWorkspaces.push({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isOwner,
|
||||
ownerId: workspace.ownerId,
|
||||
canInvite: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return adminWorkspaces
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch workspaces where user has admin access
|
||||
*/
|
||||
export function useAdminWorkspaces(userId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.adminList(userId),
|
||||
queryFn: () => fetchAdminWorkspaces(userId),
|
||||
enabled: Boolean(userId),
|
||||
staleTime: 60 * 1000, // Cache for 60 seconds
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user