chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
Checkbox,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipTag,
|
||||
Label,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { GroupDetail } from '@/ee/access-control/components/group-detail'
|
||||
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
|
||||
import {
|
||||
useCreatePermissionGroup,
|
||||
useOrganizationWorkspaces,
|
||||
usePermissionGroups,
|
||||
useUserPermissionConfig,
|
||||
} from '@/ee/access-control/hooks/permission-groups'
|
||||
|
||||
const logger = createLogger('AccessControl')
|
||||
|
||||
export function AccessControl() {
|
||||
const params = useParams()
|
||||
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
|
||||
|
||||
/**
|
||||
* Access control is governed by the workspace's OWNING organization, which may
|
||||
* differ from the caller's active org (e.g. external members). Resolve the org
|
||||
* id and the caller's admin status server-side from the workspace so gating is
|
||||
* never keyed off the session's active org.
|
||||
*/
|
||||
const { data: userPermissionConfig, isPending: entitlementLoading } =
|
||||
useUserPermissionConfig(workspaceId)
|
||||
const organizationId = userPermissionConfig?.organizationId ?? undefined
|
||||
const currentUserIsOrgAdmin = userPermissionConfig?.isOrgAdmin ?? false
|
||||
|
||||
const { data: permissionGroups = [], isPending: groupsLoading } = usePermissionGroups(
|
||||
organizationId,
|
||||
!!organizationId && currentUserIsOrgAdmin
|
||||
)
|
||||
const { data: organizationWorkspaces = [], isPending: workspacesLoading } =
|
||||
useOrganizationWorkspaces(organizationId, !!organizationId && currentUserIsOrgAdmin)
|
||||
|
||||
const accessControlEnabledLocally = isTruthy(getEnv('NEXT_PUBLIC_ACCESS_CONTROL_ENABLED'))
|
||||
const isEntitled = accessControlEnabledLocally || !!userPermissionConfig?.entitled
|
||||
const canManage = isEntitled && currentUserIsOrgAdmin && !!organizationId
|
||||
|
||||
const isLoading =
|
||||
!workspaceId ||
|
||||
entitlementLoading ||
|
||||
(!!organizationId && currentUserIsOrgAdmin && groupsLoading)
|
||||
|
||||
const createPermissionGroup = useCreatePermissionGroup()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newGroupDescription, setNewGroupDescription] = useState('')
|
||||
const [newGroupIsDefault, setNewGroupIsDefault] = useState(false)
|
||||
const [newGroupWorkspaceIds, setNewGroupWorkspaceIds] = useState<string[]>([])
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() => organizationWorkspaces.map((ws) => ({ value: ws.id, label: ws.name })),
|
||||
[organizationWorkspaces]
|
||||
)
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (!searchTerm.trim()) return permissionGroups
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
return permissionGroups.filter((g) => g.name.toLowerCase().includes(searchLower))
|
||||
}, [permissionGroups, searchTerm])
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => (selectedGroupId ? permissionGroups.find((g) => g.id === selectedGroupId) : undefined),
|
||||
[permissionGroups, selectedGroupId]
|
||||
)
|
||||
|
||||
const handleCreatePermissionGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim() || !organizationId) return
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createPermissionGroup.mutateAsync({
|
||||
organizationId,
|
||||
name: newGroupName.trim(),
|
||||
description: newGroupDescription.trim() || undefined,
|
||||
isDefault: newGroupIsDefault,
|
||||
workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupIsDefault(false)
|
||||
setNewGroupWorkspaceIds([])
|
||||
} catch (error) {
|
||||
logger.error('Failed to create permission group', error)
|
||||
setCreateError(getErrorMessage(error, 'Failed to create permission group'))
|
||||
}
|
||||
}, [
|
||||
newGroupName,
|
||||
newGroupDescription,
|
||||
newGroupIsDefault,
|
||||
newGroupWorkspaceIds,
|
||||
organizationId,
|
||||
createPermissionGroup,
|
||||
])
|
||||
|
||||
const handleCloseCreateModal = useCallback(() => {
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
setNewGroupDescription('')
|
||||
setNewGroupIsDefault(false)
|
||||
setNewGroupWorkspaceIds([])
|
||||
setCreateError(null)
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<SettingsEmptyState>
|
||||
{!organizationId
|
||||
? "Access Control applies to organization workspaces. This workspace isn't part of an organization."
|
||||
: 'Only organization admins on Enterprise plans can manage Access Control settings.'}
|
||||
</SettingsEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedGroup && organizationId) {
|
||||
return (
|
||||
<GroupDetail
|
||||
group={selectedGroup}
|
||||
organizationId={organizationId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceOptions={workspaceOptions}
|
||||
organizationWorkspaces={organizationWorkspaces}
|
||||
workspacesLoading={workspacesLoading}
|
||||
onBack={() => setSelectedGroupId(null)}
|
||||
onDeleted={() => setSelectedGroupId(null)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search permission groups...',
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
text: 'Create group',
|
||||
icon: Plus,
|
||||
variant: 'primary',
|
||||
onSelect: () => setShowCreateModal(true),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SettingsSection label={`Permission groups (${permissionGroups.length})`}>
|
||||
{permissionGroups.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No permission groups yet. Click "Create group" to get started.
|
||||
</SettingsEmptyState>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<SettingsEmptyState variant='inline'>
|
||||
No groups found matching "{searchTerm}"
|
||||
</SettingsEmptyState>
|
||||
) : (
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
{filteredGroups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type='button'
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 flex-col'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate text-[var(--text-body)] text-sm'>{group.name}</span>
|
||||
{group.isDefault && (
|
||||
<ChipTag variant='gray' className='flex-shrink-0'>
|
||||
Default
|
||||
</ChipTag>
|
||||
)}
|
||||
</div>
|
||||
<span className='truncate text-[var(--text-muted)] text-caption'>
|
||||
{group.isDefault
|
||||
? 'Everyone in the organization'
|
||||
: `${
|
||||
group.memberCount === 0
|
||||
? 'All members'
|
||||
: `${group.memberCount} member${group.memberCount === 1 ? '' : 's'}`
|
||||
} · ${group.workspaces.length} workspace${
|
||||
group.workspaces.length === 1 ? '' : 's'
|
||||
}`}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPanel>
|
||||
|
||||
<ChipModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={handleCloseCreateModal}
|
||||
size='sm'
|
||||
srTitle='Create Permission Group'
|
||||
>
|
||||
<ChipModalHeader onClose={handleCloseCreateModal}>Create Permission Group</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Name'
|
||||
value={newGroupName}
|
||||
onChange={(value) => {
|
||||
setNewGroupName(value)
|
||||
if (createError) setCreateError(null)
|
||||
}}
|
||||
placeholder='e.g., Marketing Team'
|
||||
/>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Description (optional)'
|
||||
value={newGroupDescription}
|
||||
onChange={(value) => setNewGroupDescription(value)}
|
||||
placeholder='e.g., Limited access for marketing users'
|
||||
/>
|
||||
<ChipModalField type='custom' title='Membership'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
id='default-group'
|
||||
checked={newGroupIsDefault}
|
||||
onCheckedChange={(checked) => {
|
||||
const isDefault = checked === true
|
||||
setNewGroupIsDefault(isDefault)
|
||||
if (isDefault) setNewGroupWorkspaceIds([])
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor='default-group' className='cursor-pointer font-normal'>
|
||||
Make this the organization default group
|
||||
</Label>
|
||||
</div>
|
||||
</ChipModalField>
|
||||
<ChipModalField type='custom' title='Workspaces'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<WorkspaceSelect
|
||||
workspaceIds={newGroupWorkspaceIds}
|
||||
onChange={setNewGroupWorkspaceIds}
|
||||
options={workspaceOptions}
|
||||
disabled={newGroupIsDefault}
|
||||
isLoading={workspacesLoading}
|
||||
allowAllWorkspaces={newGroupIsDefault}
|
||||
fullWidth
|
||||
/>
|
||||
{!newGroupIsDefault && (
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
Applies to all members of the selected workspaces. Restrict to specific people
|
||||
later from the group's Members section.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ChipModalField>
|
||||
<ChipModalError>{createError}</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleCloseCreateModal}
|
||||
primaryAction={{
|
||||
label: createPermissionGroup.isPending ? 'Creating...' : 'Create',
|
||||
onClick: handleCreatePermissionGroup,
|
||||
disabled:
|
||||
!newGroupName.trim() ||
|
||||
createPermissionGroup.isPending ||
|
||||
(!newGroupIsDefault && newGroupWorkspaceIds.length === 0),
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { ChipDropdown } from '@sim/emcn'
|
||||
|
||||
interface WorkspaceSelectProps {
|
||||
workspaceIds: string[]
|
||||
onChange: (ids: string[]) => void
|
||||
options: { value: string; label: string }[]
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
fullWidth?: boolean
|
||||
className?: string
|
||||
/**
|
||||
* When false, the "All workspaces" reset option is hidden and an empty
|
||||
* selection reads as a prompt. Non-default groups must target ≥1 workspace.
|
||||
*/
|
||||
allowAllWorkspaces?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace scope multi-select. With `allowAllWorkspaces` an empty selection
|
||||
* reads as "All workspaces" (the default group); otherwise it prompts for a
|
||||
* selection, since non-default groups must target specific workspaces.
|
||||
*/
|
||||
export function WorkspaceSelect({
|
||||
workspaceIds,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
allowAllWorkspaces = true,
|
||||
}: WorkspaceSelectProps) {
|
||||
return (
|
||||
<ChipDropdown
|
||||
multiple
|
||||
searchable
|
||||
align={fullWidth ? 'start' : 'end'}
|
||||
matchTriggerWidth={fullWidth}
|
||||
options={options}
|
||||
value={workspaceIds}
|
||||
onChange={onChange}
|
||||
disabled={disabled || isLoading}
|
||||
showAllOption={allowAllWorkspaces}
|
||||
allLabel={
|
||||
isLoading
|
||||
? 'Loading workspaces…'
|
||||
: allowAllWorkspaces
|
||||
? 'All workspaces'
|
||||
: 'Select workspaces…'
|
||||
}
|
||||
searchPlaceholder='Search workspaces…'
|
||||
fullWidth={fullWidth}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user