fix(settings): settings components and behavior consolidation (#2100)

* fix(settings): settings components and behavior consolidation

* ack PR comments
This commit is contained in:
Waleed
2025-11-21 19:49:32 -08:00
committed by GitHub
parent 29156e3b61
commit 33ac828d3d
17 changed files with 406 additions and 439 deletions

View File

@@ -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 }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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 */}

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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 */}

View File

@@ -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' },
})

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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,
})
}