Canonicalize settings data grid frames

This commit is contained in:
rcourtman
2026-04-29 23:35:40 +01:00
parent 65fe50ad5b
commit d77e959fb9
18 changed files with 790 additions and 713 deletions
@@ -158,6 +158,11 @@ credential, capability, status, and action cells, but table scroll framing must
stay on the shared `Table` primitive. If a configured-node table needs bounded
vertical height, apply it through `Table` `wrapperClass` instead of an outer
lifecycle-local scroll container.
Agent profile management tables follow that same presentation boundary:
`frontend-modern/src/components/Settings/AgentProfilesPanel.tsx` may own agent
profile and assignment columns, but embedded table framing must route through
`PulseDataGrid`'s shared frame variants instead of lifecycle-local
`overflow-x-auto` or side-border wrappers.
## Extension Points
@@ -97,6 +97,10 @@ Own canonical runtime payload shapes between backend and frontend.
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
10. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `security-privacy`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
The API token inventory table may own credential and usage cells, but it
must inherit embedded table framing from `frontend-primitives`
`PulseDataGrid` rather than carrying API-token-local scroll or border
wrappers around the grid.
11. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `security-privacy`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
12. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `agent-lifecycle`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
13. `frontend-modern/src/components/Settings/infrastructureOperationsModel.tsx` shared with `agent-lifecycle`: the pure infrastructure operations inventory/install model is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
@@ -195,7 +195,11 @@ work extends shared components instead of creating new local variants.
tables, Ceph tables, PMG resource panels, and `PulseDataGrid`, must follow
the same rule: feature owners may pass `wrapperClass` for bounded height,
border, radius, or scrollbar hiding, but they must not add raw table markup
or local scroll wrappers around the shared table primitive.
or local scroll wrappers around the shared table primitive. `PulseDataGrid`
also owns its root frame variants: feature surfaces embedded directly inside
an existing panel/card frame must use the shared `frame="flush"` mode rather
than caller-local border overrides, horizontal-scroll wrappers, or negative
margin compensation.
Product-table subgroup/header rows must likewise consume the shared
`frontend-modern/src/components/shared/groupedTableRowPresentation.ts`
helper and `.grouped-table-row` CSS token contract instead of local
@@ -71,6 +71,11 @@ create, review, and approve cross-organization shares.
1. Add or change organization role and share semantics through `internal/models/organization.go`
2. Add or change organization access, overview, sharing, RBAC feature-gating, role-management, or user-assignment presentation through `frontend-modern/src/components/Settings/OrganizationAccessPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessManagementSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessInvitationsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationAccessMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewDetailsSection.tsx`, `frontend-modern/src/components/Settings/OrganizationOverviewMembersSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingCreateSection.tsx`, `frontend-modern/src/components/Settings/OrganizationSharingLoadingState.tsx`, `frontend-modern/src/components/Settings/OrganizationOutgoingSharesSection.tsx`, `frontend-modern/src/components/Settings/OrganizationIncomingSharesSection.tsx`, `frontend-modern/src/components/Settings/settingsPanelRegistryContext.tsx`, `frontend-modern/src/components/Settings/useOrganizationAccessPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationOverviewPanelState.ts`, `frontend-modern/src/components/Settings/useOrganizationSharingPanelState.ts`, `frontend-modern/src/components/Settings/RBACFeatureGateSection.tsx`, `frontend-modern/src/components/Settings/RolesPanel.tsx`, `frontend-modern/src/components/Settings/RolesEditorDialog.tsx`, `frontend-modern/src/components/Settings/useRBACFeatureGateState.ts`, `frontend-modern/src/components/Settings/useRolesPanelState.ts`, `frontend-modern/src/components/Settings/UserAssignmentsPanel.tsx`, `frontend-modern/src/components/Settings/UserAssignmentsDialog.tsx`, and `frontend-modern/src/components/Settings/useUserAssignmentsPanelState.ts`
Organization and RBAC tables may own their rows, columns, action cells, and
domain copy, but their table scroll shell and embedded-frame treatment must
come from `frontend-primitives` through `PulseDataGrid`/`Table`; do not add
organization-local `overflow-x-auto`, negative-margin, or side-border
compensation around grids.
3. Route organization and RBAC frontend transport changes through `frontend-modern/src/api/orgs.ts` and `frontend-modern/src/api/rbac.ts`
4. Keep backend organization management and lifecycle handlers aligned through `internal/api/org_handlers.go` and `internal/api/org_lifecycle_handlers.go`
5. Keep RBAC role, assignment, and admin recovery transport aligned through `internal/api/access_control_handlers.go` and `internal/api/enterprise_extension_rbac_admin.go`
@@ -880,6 +880,7 @@
"exact_files": [
"frontend-modern/src/api/__tests__/agentProfiles.test.ts",
"frontend-modern/src/components/Settings/__tests__/AgentProfilesPanel.test.tsx",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/utils/__tests__/agentProfilesPresentation.test.ts"
]
},
@@ -1656,7 +1657,8 @@
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/Settings/__tests__/APITokenManager.test.tsx",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts"
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
]
},
{
@@ -3949,6 +3951,7 @@
"frontend-modern/src/components/Settings/__tests__/OrganizationOverviewPanel.test.tsx",
"frontend-modern/src/components/Settings/__tests__/OrganizationSharingPanel.test.tsx",
"frontend-modern/src/components/Settings/__tests__/RBACPaywallPanels.test.tsx",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts",
"frontend-modern/src/utils/__tests__/organizationRolePresentation.test.ts",
"frontend-modern/src/utils/__tests__/organizationSettingsPresentation.test.ts",
@@ -4624,6 +4627,7 @@
"frontend-modern/src/components/Settings/__tests__/dataHandlingPanelModel.test.ts",
"frontend-modern/src/components/Settings/__tests__/SecurityPostureSummary.test.tsx",
"frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/stores/__tests__/systemSettings.test.ts"
]
},
@@ -64,6 +64,10 @@ visibility, and privacy controls to operators.
1. `frontend-modern/src/api/security.ts` shared with `api-contracts`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
2. `frontend-modern/src/components/Settings/APITokenManager.tsx` shared with `api-contracts`: the API token settings surface is both a security/privacy control surface and a canonical API payload contract boundary.
Token-management table rows are security-facing content, but the visual
table frame and scroll shell belong to `frontend-primitives`
`PulseDataGrid`; do not add token-surface-local overflow, side-border, or
negative-margin wrappers around the inventory grid.
3. `frontend-modern/src/components/Settings/apiTokenManagerModel.ts` shared with `api-contracts`: the pure API token settings model is both a security/privacy control surface and a canonical API payload contract boundary.
4. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` shared with `frontend-primitives`: the data-handling settings surface is both a security/privacy trust surface and a canonical settings-shell presentation boundary.
5. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` shared with `frontend-primitives`: the data-handling settings model is both a security/privacy posture projection and a canonical settings-shell presentation boundary.
@@ -270,152 +270,149 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
</button>
</div>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={sortedTokens()}
columns={[
{
key: 'name',
label: 'Name',
render: (token) => (
<span class="font-medium text-base-content">{token.name || 'Untitled'}</span>
),
<PulseDataGrid
data={sortedTokens()}
columns={[
{
key: 'name',
label: 'Name',
render: (token) => (
<span class="font-medium text-base-content">{token.name || 'Untitled'}</span>
),
},
{
key: 'hint',
label: 'Hint',
render: (token) => (
<span class="font-mono text-xs text-muted">{tokenHint(token)}</span>
),
},
{
key: 'scopes',
label: 'Scopes',
render: (token) => {
const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
const scopeBadges = rawScopes.includes('*')
? [{ value: '*', label: 'Full' }]
: rawScopes.map((scope) => ({
value: scope,
label: API_SCOPE_LABELS[scope] ?? scope,
}));
return (
<div class="flex flex-wrap gap-1.5">
<For each={scopeBadges}>
{(scope) => {
const isWildcard = scope.value === '*';
return (
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
isWildcard
? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
: 'bg-surface-alt text-base-content'
}`}
title={scope.value}
>
{scope.label}
</span>
);
}}
</For>
</div>
);
},
{
key: 'hint',
label: 'Hint',
render: (token) => (
<span class="font-mono text-xs text-muted">{tokenHint(token)}</span>
),
},
{
key: 'scopes',
label: 'Scopes',
render: (token) => {
const rawScopes =
token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
const scopeBadges = rawScopes.includes('*')
? [{ value: '*', label: 'Full' }]
: rawScopes.map((scope) => ({
value: scope,
label: API_SCOPE_LABELS[scope] ?? scope,
}));
return (
<div class="flex flex-wrap gap-1.5">
<For each={scopeBadges}>
{(scope) => {
const isWildcard = scope.value === '*';
return (
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
isWildcard
? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
: 'bg-surface-alt text-base-content'
}`}
title={scope.value}
>
{scope.label}
</span>
);
}}
</For>
</div>
},
{
key: 'usage',
label: 'Usage',
render: (token) => {
const dockerUsageEntry = dockerTokenUsage().get(token.id);
const agentUsageEntry = agentTokenUsage().get(token.id);
const usageSegments: string[] = [];
const usageTitleSegments: string[] = [];
if (dockerUsageEntry) {
usageSegments.push(
dockerUsageEntry.count === 1
? (dockerUsageEntry.items[0]?.label ?? 'Container runtime')
: `${dockerUsageEntry.count} container runtimes`,
);
},
},
{
key: 'usage',
label: 'Usage',
render: (token) => {
const dockerUsageEntry = dockerTokenUsage().get(token.id);
const agentUsageEntry = agentTokenUsage().get(token.id);
const usageSegments: string[] = [];
const usageTitleSegments: string[] = [];
if (dockerUsageEntry) {
usageSegments.push(
dockerUsageEntry.count === 1
? (dockerUsageEntry.items[0]?.label ?? 'Container runtime')
: `${dockerUsageEntry.count} container runtimes`,
);
usageTitleSegments.push(
`Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`,
);
}
if (agentUsageEntry) {
usageSegments.push(
agentUsageEntry.count === 1
? `${agentUsageEntry.items[0]?.label ?? 'Agent'}`
: `${agentUsageEntry.count} agents`,
);
usageTitleSegments.push(
`Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`,
);
}
const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—';
return (
<div
class="flex flex-wrap items-center gap-2"
title={
usageTitleSegments.length > 0 ? usageTitleSegments.join('\n') : undefined
}
>
<span class="text-muted">{usageSummary}</span>
<Show when={agentUsageEntry && agentUsageEntry.count > 1}>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-200">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"
clip-rule="evenodd"
/>
</svg>
Agents sharing this token ({agentUsageEntry!.count})
</span>
</Show>
</div>
usageTitleSegments.push(
`Container runtimes: ${dockerUsageEntry.items.map((runtime) => runtime.label).join(', ')}`,
);
},
},
{
key: 'createdAt',
label: 'Created',
render: (token) => (
<span class="text-muted">
{formatRelativeTime(new Date(token.createdAt).getTime())}
</span>
),
},
{
key: 'lastUsedAt',
label: 'Last used',
render: (token) => (
<span class="text-muted">
{token.lastUsedAt
? formatRelativeTime(new Date(token.lastUsedAt).getTime())
: 'Never'}
</span>
),
},
{
key: 'action',
label: 'Action',
align: 'right',
render: (token) => (
<button
onClick={() => handleDelete(token)}
disabled={!canManage()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
}
if (agentUsageEntry) {
usageSegments.push(
agentUsageEntry.count === 1
? `${agentUsageEntry.items[0]?.label ?? 'Agent'}`
: `${agentUsageEntry.count} agents`,
);
usageTitleSegments.push(
`Agents: ${agentUsageEntry.items.map((agent) => agent.label).join(', ')}`,
);
}
const usageSummary = usageSegments.length > 0 ? usageSegments.join(' • ') : '—';
return (
<div
class="flex flex-wrap items-center gap-2"
title={
usageTitleSegments.length > 0 ? usageTitleSegments.join('\n') : undefined
}
>
Revoke
</button>
),
<span class="text-muted">{usageSummary}</span>
<Show when={agentUsageEntry && agentUsageEntry.count > 1}>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-200">
<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M8.257 3.099c.764-1.36 2.722-1.36 3.486 0l6.518 11.62c.75 1.338-.213 3.005-1.743 3.005H3.482c-1.53 0-2.493-1.667-1.743-3.005l6.518-11.62ZM11 5a1 1 0 1 0-2 0v4.5a1 1 0 1 0 2 0V5Zm0 8a1 1 0 1 0-2 0 1 1 0 0 0 2 0Z"
clip-rule="evenodd"
/>
</svg>
Agents sharing this token ({agentUsageEntry!.count})
</span>
</Show>
</div>
);
},
]}
keyExtractor={(token) => token.id}
desktopMinWidth="1000px"
class="border-x-0 sm:border-x border-t-0 rounded-t-none"
/>
</div>
},
{
key: 'createdAt',
label: 'Created',
render: (token) => (
<span class="text-muted">
{formatRelativeTime(new Date(token.createdAt).getTime())}
</span>
),
},
{
key: 'lastUsedAt',
label: 'Last used',
render: (token) => (
<span class="text-muted">
{token.lastUsedAt
? formatRelativeTime(new Date(token.lastUsedAt).getTime())
: 'Never'}
</span>
),
},
{
key: 'action',
label: 'Action',
align: 'right',
render: (token) => (
<button
onClick={() => handleDelete(token)}
disabled={!canManage()}
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
>
Revoke
</button>
),
},
]}
keyExtractor={(token) => token.id}
desktopMinWidth="1000px"
frame="flush"
/>
</Card>
</Show>
@@ -157,66 +157,64 @@ export const AgentProfilesPanel: Component = () => {
</Show>
<Show when={!loading() && profiles().length > 0}>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={profiles()}
columns={[
{
key: 'name',
label: 'Name',
render: (profile) => (
<span class="font-medium text-base-content">{profile.name}</span>
),
},
{
key: 'settings',
label: 'Settings',
render: (profile) => (
<span class="text-muted">{getSettingsCount(profile)}</span>
),
},
{
key: 'agents',
label: 'Agents',
render: (profile) => (
<span class="inline-flex items-center gap-1 text-muted">
<Users class="w-4 h-4" />
{getAssignmentCount(profile.id)}
</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (profile) => (
<div class="inline-flex items-center gap-1">
<button
type="button"
onClick={() => handleEdit(profile)}
class="p-1.5 rounded-md hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900"
title="Edit profile"
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(profile)}
class="p-1.5 rounded-md hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
title="Delete profile"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
),
},
]}
keyExtractor={(profile) => profile.id}
emptyState={getAgentProfilesEmptyState()}
desktopMinWidth="600px"
class="border-x-0 sm:border-x border-border"
/>
</div>
<PulseDataGrid
data={profiles()}
columns={[
{
key: 'name',
label: 'Name',
render: (profile) => (
<span class="font-medium text-base-content">{profile.name}</span>
),
},
{
key: 'settings',
label: 'Settings',
render: (profile) => (
<span class="text-muted">{getSettingsCount(profile)}</span>
),
},
{
key: 'agents',
label: 'Agents',
render: (profile) => (
<span class="inline-flex items-center gap-1 text-muted">
<Users class="w-4 h-4" />
{getAssignmentCount(profile.id)}
</span>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (profile) => (
<div class="inline-flex items-center gap-1">
<button
type="button"
onClick={() => handleEdit(profile)}
class="p-1.5 rounded-md hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900"
title="Edit profile"
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleDelete(profile)}
class="p-1.5 rounded-md hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
title="Delete profile"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
),
},
]}
keyExtractor={(profile) => profile.id}
emptyState={getAgentProfilesEmptyState()}
desktopMinWidth="600px"
frame="flush"
/>
</Show>
</SettingsPanel>
@@ -235,93 +233,88 @@ export const AgentProfilesPanel: Component = () => {
</Show>
<Show when={connectedAgents().length > 0}>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={connectedAgents()}
columns={[
{
key: 'agent',
label: 'Agent',
render: (agent) => (
<div>
<span class="font-medium text-base-content">
{agent.displayName || agent.hostname}
</span>
<Show when={agent.displayName && agent.hostname !== agent.displayName}>
<span class="ml-2 text-xs text-muted">({agent.hostname})</span>
</Show>
</div>
),
},
{
key: 'profile',
label: 'Profile',
render: (agent) => {
const assignment = () => getAgentAssignment(agent.assignmentId);
return (
<FormSelect
label={`Profile for ${agent.displayName || agent.hostname}`}
labelClass="sr-only"
fieldBaseClass="contents"
value={assignment()?.profile_id || ''}
onChange={(e) =>
handleAssign(agent.assignmentId, e.currentTarget.value)
}
selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">No profile</option>
<Show
when={
assignment()?.profile_id &&
!getProfileById(assignment()!.profile_id)
}
>
<option value={assignment()!.profile_id}>
{getProfileOptionLabel(assignment()!.profile_id)}
</option>
</Show>
<For each={profiles()}>
{(profile) => (
<option value={profile.id}>
{getProfileOptionLabel(profile.id)}
</option>
)}
</For>
</FormSelect>
);
},
},
{
key: 'status',
label: 'Status',
render: (agent) => {
const indicator = () => getAgentStatusIndicator({ status: agent.status });
return (
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
>
{indicator().label}
</span>
);
},
},
{
key: 'lastSeen',
label: 'Last Seen',
hiddenOnMobile: true,
render: (agent) => (
<span class="text-muted">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'}
<PulseDataGrid
data={connectedAgents()}
columns={[
{
key: 'agent',
label: 'Agent',
render: (agent) => (
<div>
<span class="font-medium text-base-content">
{agent.displayName || agent.hostname}
</span>
),
<Show when={agent.displayName && agent.hostname !== agent.displayName}>
<span class="ml-2 text-xs text-muted">({agent.hostname})</span>
</Show>
</div>
),
},
{
key: 'profile',
label: 'Profile',
render: (agent) => {
const assignment = () => getAgentAssignment(agent.assignmentId);
return (
<FormSelect
label={`Profile for ${agent.displayName || agent.hostname}`}
labelClass="sr-only"
fieldBaseClass="contents"
value={assignment()?.profile_id || ''}
onChange={(e) => handleAssign(agent.assignmentId, e.currentTarget.value)}
selectBaseClass="min-h-10 sm:min-h-9 w-full sm:max-w-xs rounded-md border border-border bg-surface px-2.5 py-1.5 text-sm text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">No profile</option>
<Show
when={
assignment()?.profile_id && !getProfileById(assignment()!.profile_id)
}
>
<option value={assignment()!.profile_id} selected>
{getProfileOptionLabel(assignment()!.profile_id)}
</option>
</Show>
<For each={profiles()}>
{(profile) => (
<option value={profile.id}>
{getProfileOptionLabel(profile.id)}
</option>
)}
</For>
</FormSelect>
);
},
]}
keyExtractor={(agent) => agent.id}
emptyState={getAgentProfileAssignmentsEmptyState()}
desktopMinWidth="800px"
class="border-x-0 sm:border-x"
/>
</div>
},
{
key: 'status',
label: 'Status',
render: (agent) => {
const indicator = () => getAgentStatusIndicator({ status: agent.status });
return (
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
>
{indicator().label}
</span>
);
},
},
{
key: 'lastSeen',
label: 'Last Seen',
hiddenOnMobile: true,
render: (agent) => (
<span class="text-muted">
{agent.lastSeen ? formatRelativeTime(agent.lastSeen) : 'Never'}
</span>
),
},
]}
keyExtractor={(agent) => agent.id}
emptyState={getAgentProfileAssignmentsEmptyState()}
desktopMinWidth="800px"
frame="flush"
/>
</Show>
</SettingsPanel>
@@ -18,97 +18,96 @@ export const OrganizationAccessMembersSection: Component<OrganizationAccessMembe
) => (
<Show when={props.state.org()}>
{(currentOrg) => (
<div class="mt-4">
<PulseDataGrid
data={props.state.members()}
columns={[
{
key: 'userId',
label: 'User',
render: (member) => <span class="text-base-content">{member.userId}</span>,
},
{
key: 'role',
label: 'Role',
render: (member) => {
const role = normalizeRole(member.role);
const isOwner = () => member.userId === currentOrg().ownerUserId;
return (
<Show
when={props.state.canManageCurrentOrg()}
fallback={
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
<PulseDataGrid
class="mt-4"
data={props.state.members()}
columns={[
{
key: 'userId',
label: 'User',
render: (member) => <span class="text-base-content">{member.userId}</span>,
},
{
key: 'role',
label: 'Role',
render: (member) => {
const role = normalizeRole(member.role);
const isOwner = () => member.userId === currentOrg().ownerUserId;
return (
<Show
when={props.state.canManageCurrentOrg()}
fallback={
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
}
>
<FormSelect
label={`Role for ${member.userId}`}
labelClass="sr-only"
fieldBaseClass="contents"
value={role}
onChange={(event) => {
void props.state.updateRole(
member,
event.currentTarget.value as OrganizationRole,
);
}}
disabled={
props.state.saving() ||
(isOwner() && props.currentUser !== currentOrg().ownerUserId)
}
selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
>
<FormSelect
label={`Role for ${member.userId}`}
labelClass="sr-only"
fieldBaseClass="contents"
value={role}
onChange={(event) => {
void props.state.updateRole(
member,
event.currentTarget.value as OrganizationRole,
);
}}
disabled={
props.state.saving() ||
(isOwner() && props.currentUser !== currentOrg().ownerUserId)
}
selectBaseClass="rounded-md border border-border bg-surface px-2 py-1 text-xs text-base-content shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-60"
<For
each={ORGANIZATION_MEMBER_ROLE_OPTIONS.filter(
(option) =>
option.value !== 'owner' ||
props.currentUser === currentOrg().ownerUserId,
)}
>
<For
each={ORGANIZATION_MEMBER_ROLE_OPTIONS.filter(
(option) =>
option.value !== 'owner' ||
props.currentUser === currentOrg().ownerUserId,
)}
>
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</FormSelect>
</Show>
);
},
{(option) => <option value={option.value}>{option.label}</option>}
</For>
</FormSelect>
</Show>
);
},
{
key: 'addedAt',
label: 'Added',
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
},
{
key: 'addedAt',
label: 'Added',
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (member: OrganizationMember) => {
const isOwner = () => member.userId === currentOrg().ownerUserId;
return (
<Show when={props.state.canManageCurrentOrg() && !isOwner()}>
<button
type="button"
onClick={() => {
void props.state.removeMember(member);
}}
disabled={props.state.saving()}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 class="w-3.5 h-3.5" />
Remove
</button>
</Show>
);
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (member: OrganizationMember) => {
const isOwner = () => member.userId === currentOrg().ownerUserId;
return (
<Show when={props.state.canManageCurrentOrg() && !isOwner()}>
<button
type="button"
onClick={() => {
void props.state.removeMember(member);
}}
disabled={props.state.saving()}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 class="w-3.5 h-3.5" />
Remove
</button>
</Show>
);
},
},
]}
keyExtractor={(member) => member.userId}
emptyState={getOrganizationAccessEmptyState()}
desktopMinWidth="700px"
/>
</div>
},
]}
keyExtractor={(member) => member.userId}
emptyState={getOrganizationAccessEmptyState()}
desktopMinWidth="700px"
/>
)}
</Show>
);
@@ -24,108 +24,106 @@ export const OrganizationIncomingSharesSection: Component<
return (
<div class="space-y-2 p-4 sm:p-6">
<h4 class="text-sm font-semibold text-base-content">Incoming Shares</h4>
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
<PulseDataGrid
data={props.state.incomingShares()}
columns={[
{
key: 'sourceOrg',
label: 'Source Org',
render: (share) => (
<span class="text-base-content">{share.sourceOrgName || share.sourceOrgId}</span>
),
<PulseDataGrid
class="mt-4"
data={props.state.incomingShares()}
columns={[
{
key: 'sourceOrg',
label: 'Source Org',
render: (share) => (
<span class="text-base-content">{share.sourceOrgName || share.sourceOrgId}</span>
),
},
{
key: 'resource',
label: 'Resource',
render: (share) => (
<div class="flex flex-col">
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
<span class="text-xs text-muted">
{share.resourceType}:{share.resourceId}
</span>
</div>
),
},
{
key: 'accessRole',
label: 'Access',
render: (share) => {
const role = normalizeOrganizationShareRole(share.accessRole);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
{
key: 'resource',
label: 'Resource',
render: (share) => (
<div class="flex flex-col">
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
<span class="text-xs text-muted">
{share.resourceType}:{share.resourceId}
</span>
</div>
),
},
{
key: 'accessRole',
label: 'Access',
render: (share) => {
const role = normalizeOrganizationShareRole(share.accessRole);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
},
{
key: 'status',
label: 'Status',
render: (share) => (
<div class="flex flex-col gap-1">
<span
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
>
{getOrganizationShareStatusLabel(share.status)}
</span>
<span class="text-xs text-muted">
{getOrganizationShareStatusDescription(
share.status,
share.acceptedAt,
share.acceptedBy,
)}
</span>
</div>
),
},
{
key: 'createdAt',
label: 'Requested',
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (share) => (
<Show when={props.state.canManageCurrentOrg()}>
<div class="flex items-center justify-end gap-2">
<Show when={share.status === 'pending'}>
<button
type="button"
onClick={() => {
void props.state.acceptIncomingShare(share);
}}
disabled={props.state.saving()}
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900 disabled:cursor-not-allowed disabled:opacity-60"
>
Accept
</button>
</Show>
},
{
key: 'status',
label: 'Status',
render: (share) => (
<div class="flex flex-col gap-1">
<span
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
>
{getOrganizationShareStatusLabel(share.status)}
</span>
<span class="text-xs text-muted">
{getOrganizationShareStatusDescription(
share.status,
share.acceptedAt,
share.acceptedBy,
)}
</span>
</div>
),
},
{
key: 'createdAt',
label: 'Requested',
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (share) => (
<Show when={props.state.canManageCurrentOrg()}>
<div class="flex items-center justify-end gap-2">
<Show when={share.status === 'pending'}>
<button
type="button"
onClick={() => {
void props.state.declineIncomingShare(share);
void props.state.acceptIncomingShare(share);
}}
disabled={props.state.saving()}
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{share.status === 'pending' ? 'Decline' : 'Remove'}
Accept
</button>
</div>
</Show>
),
},
]}
keyExtractor={(share) => share.id}
emptyState={getOrganizationIncomingSharesEmptyState()}
desktopMinWidth="980px"
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
/>
</div>
</Show>
<button
type="button"
onClick={() => {
void props.state.declineIncomingShare(share);
}}
disabled={props.state.saving()}
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{share.status === 'pending' ? 'Decline' : 'Remove'}
</button>
</div>
</Show>
),
},
]}
keyExtractor={(share) => share.id}
emptyState={getOrganizationIncomingSharesEmptyState()}
desktopMinWidth="980px"
/>
</div>
);
};
@@ -25,97 +25,95 @@ export const OrganizationOutgoingSharesSection: Component<
return (
<div class="space-y-2 p-4 sm:p-6">
<h4 class="text-sm font-semibold text-base-content">Outgoing Shares</h4>
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
<PulseDataGrid
data={props.state.outgoingShares()}
columns={[
{
key: 'resourceName',
label: 'Resource',
render: (share) => (
<div class="flex flex-col">
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
<span class="text-xs text-muted">
{share.resourceType}:{share.resourceId}
</span>
</div>
),
},
{
key: 'targetOrgId',
label: 'Target Org',
render: (share) => (
<span class="text-base-content">
{props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId}
<PulseDataGrid
class="mt-4"
data={props.state.outgoingShares()}
columns={[
{
key: 'resourceName',
label: 'Resource',
render: (share) => (
<div class="flex flex-col">
<span class="text-base-content">{share.resourceName || share.resourceId}</span>
<span class="text-xs text-muted">
{share.resourceType}:{share.resourceId}
</span>
),
</div>
),
},
{
key: 'targetOrgId',
label: 'Target Org',
render: (share) => (
<span class="text-base-content">
{props.state.orgNameById().get(share.targetOrgId) || share.targetOrgId}
</span>
),
},
{
key: 'accessRole',
label: 'Access',
render: (share) => {
const role = normalizeOrganizationShareRole(share.accessRole);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
{
key: 'accessRole',
label: 'Access',
render: (share) => {
const role = normalizeOrganizationShareRole(share.accessRole);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
},
{
key: 'status',
label: 'Status',
render: (share) => (
<div class="flex flex-col gap-1">
<span
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
>
{getOrganizationShareStatusLabel(share.status)}
</span>
<span class="text-xs text-muted">
{getOrganizationShareStatusDescription(
share.status,
share.acceptedAt,
share.acceptedBy,
)}
</span>
</div>
),
},
{
key: 'createdAt',
label: 'Requested',
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (share) => (
<Show when={props.state.canManageCurrentOrg()}>
<button
type="button"
onClick={() => {
void props.state.deleteShare(share);
}}
disabled={props.state.saving()}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 class="w-3.5 h-3.5" />
Remove
</button>
</Show>
),
},
]}
keyExtractor={(share) => share.id}
emptyState={getOrganizationOutgoingSharesEmptyState()}
desktopMinWidth="980px"
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
/>
</div>
},
{
key: 'status',
label: 'Status',
render: (share) => (
<div class="flex flex-col gap-1">
<span
class={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(share.status)}`}
>
{getOrganizationShareStatusLabel(share.status)}
</span>
<span class="text-xs text-muted">
{getOrganizationShareStatusDescription(
share.status,
share.acceptedAt,
share.acceptedBy,
)}
</span>
</div>
),
},
{
key: 'createdAt',
label: 'Requested',
render: (share) => <span class="text-muted">{formatOrgDate(share.createdAt)}</span>,
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (share) => (
<Show when={props.state.canManageCurrentOrg()}>
<button
type="button"
onClick={() => {
void props.state.deleteShare(share);
}}
disabled={props.state.saving()}
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-300 dark:hover:bg-red-900 disabled:cursor-not-allowed disabled:opacity-60"
>
<Trash2 class="w-3.5 h-3.5" />
Remove
</button>
</Show>
),
},
]}
keyExtractor={(share) => share.id}
emptyState={getOrganizationOutgoingSharesEmptyState()}
desktopMinWidth="980px"
/>
</div>
);
};
@@ -13,40 +13,38 @@ export const OrganizationOverviewMembersSection: Component<
> = (props) => (
<div class="space-y-2 p-4 sm:p-6">
<h4 class="text-sm font-semibold text-base-content">Membership</h4>
<div class="mt-4 -mx-4 sm:mx-0 overflow-x-auto w-full">
<PulseDataGrid
data={props.state.members()}
columns={[
{
key: 'userId',
label: 'User',
render: (member) => <span class="text-base-content">{member.userId}</span>,
<PulseDataGrid
class="mt-4"
data={props.state.members()}
columns={[
{
key: 'userId',
label: 'User',
render: (member) => <span class="text-base-content">{member.userId}</span>,
},
{
key: 'role',
label: 'Role',
render: (member) => {
const role = normalizeRole(member.role);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
{
key: 'role',
label: 'Role',
render: (member) => {
const role = normalizeRole(member.role);
return (
<span
class={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(role)}`}
>
{role}
</span>
);
},
},
{
key: 'addedAt',
label: 'Added',
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
},
]}
keyExtractor={(member) => member.userId}
emptyState={getOrganizationOverviewMembersEmptyState()}
desktopMinWidth="560px"
class="border-x-0 sm:border-x sm:border-t sm:border-b sm:rounded-md border-y border-border"
/>
</div>
},
{
key: 'addedAt',
label: 'Added',
render: (member) => <span class="text-muted">{formatOrgDate(member.addedAt)}</span>,
},
]}
keyExtractor={(member) => member.userId}
emptyState={getOrganizationOverviewMembersEmptyState()}
desktopMinWidth="560px"
/>
</div>
);
@@ -47,77 +47,75 @@ export const RolesPanel: Component = () => {
</Show>
<Show when={!state.loading() && state.featureGate.rbacEnabled()}>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={state.roles()}
columns={[
{
key: 'role',
label: 'Role',
render: (role) => (
<div class="flex flex-col">
<span class="font-medium text-base-content flex items-center gap-1">
{role.name}
<Show when={role.isBuiltIn}>
<BadgeCheck class="w-4 h-4 text-blue-500" />
</Show>
</span>
<span class="text-xs text-muted">{role.description}</span>
</div>
),
},
{
key: 'permissions',
label: 'Permissions',
render: (role) => (
<div class="flex flex-wrap gap-1">
<For each={role.permissions}>
{(perm) => (
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted border border-border">
{perm.action}:{perm.resource}
</span>
)}
</For>
</div>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (role) => (
<div class="inline-flex items-center gap-1">
<Show when={!role.isBuiltIn}>
<button
type="button"
onClick={() => state.openEditRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300"
title="Edit role"
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
onClick={() => state.handleDeleteRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
title="Delete role"
>
<Trash2 class="w-4 h-4" />
</button>
</Show>
<PulseDataGrid
data={state.roles()}
columns={[
{
key: 'role',
label: 'Role',
render: (role) => (
<div class="flex flex-col">
<span class="font-medium text-base-content flex items-center gap-1">
{role.name}
<Show when={role.isBuiltIn}>
<span class="text-xs text-slate-400 italic">Read-only</span>
<BadgeCheck class="w-4 h-4 text-blue-500" />
</Show>
</div>
),
},
]}
keyExtractor={(role) => role.id}
emptyState={getRolesEmptyState()}
desktopMinWidth="620px"
class="border-x-0 sm:border-x"
/>
</div>
</span>
<span class="text-xs text-muted">{role.description}</span>
</div>
),
},
{
key: 'permissions',
label: 'Permissions',
render: (role) => (
<div class="flex flex-wrap gap-1">
<For each={role.permissions}>
{(perm) => (
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-muted border border-border">
{perm.action}:{perm.resource}
</span>
)}
</For>
</div>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (role) => (
<div class="inline-flex items-center gap-1">
<Show when={!role.isBuiltIn}>
<button
type="button"
onClick={() => state.openEditRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-blue-600 hover:bg-surface-hover dark:hover:text-blue-300"
title="Edit role"
>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
onClick={() => state.handleDeleteRole(role)}
class="p-1.5 rounded-md text-slate-500 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900"
title="Delete role"
>
<Trash2 class="w-4 h-4" />
</button>
</Show>
<Show when={role.isBuiltIn}>
<span class="text-xs text-slate-400 italic">Read-only</span>
</Show>
</div>
),
},
]}
keyExtractor={(role) => role.id}
emptyState={getRolesEmptyState()}
desktopMinWidth="620px"
frame="flush"
/>
</Show>
</SettingsPanel>
@@ -75,58 +75,56 @@ export const UserAssignmentsPanel: Component = () => {
state.filteredAssignments().length > 0
}
>
<div class="w-full overflow-x-auto">
<PulseDataGrid
data={state.filteredAssignments()}
columns={[
{
key: 'username',
label: 'Username',
render: (assignment) => (
<span class="font-medium text-base-content">{assignment.username}</span>
),
},
{
key: 'assignedRoles',
label: 'Assigned Roles',
render: (assignment) => (
<div class="flex flex-wrap gap-1">
<Show when={assignment.roleIds.length === 0}>
<span class="text-xs text-slate-400 italic">No roles assigned</span>
</Show>
<For each={assignment.roleIds}>
{(roleId) => (
<span class="inline-flex items-center gap-1 rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content border border-border">
<Shield class="w-3 h-3" />
{state.getRoleName(roleId)}
</span>
)}
</For>
</div>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (assignment) => (
<button
type="button"
onClick={() => state.openManageAccess(assignment)}
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
>
<Pencil class="w-4 h-4" />
Manage Access
</button>
),
},
]}
keyExtractor={(assignment) => assignment.username}
emptyState={emptyStateCopy().title}
desktopMinWidth="620px"
class="border-x-0 sm:border-x"
/>
</div>
<PulseDataGrid
data={state.filteredAssignments()}
columns={[
{
key: 'username',
label: 'Username',
render: (assignment) => (
<span class="font-medium text-base-content">{assignment.username}</span>
),
},
{
key: 'assignedRoles',
label: 'Assigned Roles',
render: (assignment) => (
<div class="flex flex-wrap gap-1">
<Show when={assignment.roleIds.length === 0}>
<span class="text-xs text-slate-400 italic">No roles assigned</span>
</Show>
<For each={assignment.roleIds}>
{(roleId) => (
<span class="inline-flex items-center gap-1 rounded-md bg-surface-alt px-2 py-0.5 text-xs font-medium text-base-content border border-border">
<Shield class="w-3 h-3" />
{state.getRoleName(roleId)}
</span>
)}
</For>
</div>
),
},
{
key: 'actions',
label: 'Actions',
align: 'right',
render: (assignment) => (
<button
type="button"
onClick={() => state.openManageAccess(assignment)}
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium text-base-content hover:bg-surface-hover transition-colors"
>
<Pencil class="w-4 h-4" />
Manage Access
</button>
),
},
]}
keyExtractor={(assignment) => assignment.username}
emptyState={emptyStateCopy().title}
desktopMinWidth="620px"
frame="flush"
/>
</Show>
</SettingsPanel>
@@ -9,6 +9,7 @@ import {
} from '@/components/shared/Table';
import {
getPulseDataGridAlignClass,
getPulseDataGridFrameClass,
getPulseDataGridWidthAttr,
isPulseDataGridInteractiveTarget,
type PulseDataGridProps,
@@ -36,12 +37,13 @@ export function PulseDataGrid<T>(props: PulseDataGridProps<T>) {
'desktopMinWidth',
'mobileMinWidth',
'class',
'frame',
]);
const grid = usePulseDataGridState(local);
return (
<div class={`overflow-hidden rounded-md border border-border bg-surface ${local.class || ''}`}>
<div class={`${getPulseDataGridFrameClass(local.frame)} ${local.class || ''}`}>
<Table
wrapperClass="scrollbar-hide"
class="w-full border-collapse"
@@ -163,9 +163,17 @@ import pmgMailGatewaySource from '@/components/PMG/MailGateway.tsx?raw';
import pmgInstancePanelSource from '@/components/PMG/PMGInstancePanel.tsx?raw';
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw';
import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw';
import apiTokenManagerSource from '@/components/Settings/APITokenManager.tsx?raw';
import generalSettingsPanelSource from '@/components/Settings/GeneralSettingsPanel.tsx?raw';
import organizationAccessMembersSectionSource from '@/components/Settings/OrganizationAccessMembersSection.tsx?raw';
import organizationIncomingSharesSectionSource from '@/components/Settings/OrganizationIncomingSharesSection.tsx?raw';
import organizationOutgoingSharesSectionSource from '@/components/Settings/OrganizationOutgoingSharesSection.tsx?raw';
import organizationOverviewMembersSectionSource from '@/components/Settings/OrganizationOverviewMembersSection.tsx?raw';
import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
const sharedSources = import.meta.glob(['./*.tsx', './cards/*.tsx', './responsive/*.tsx'], {
query: '?raw',
@@ -593,10 +601,32 @@ describe('shared primitive guardrails', () => {
expect(configuredNodeTablesSource).toContain('wrapperClass="max-h-[600px] overflow-y-auto"');
expect(storageContentCardSource).toContain('<StoragePoolsTable');
expect(pulseDataGridSource).toContain('wrapperClass="scrollbar-hide"');
expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass');
expect(pulseDataGridSource).not.toContain(
'<div class="overflow-x-auto touch-scroll scrollbar-hide">',
);
for (const source of [
agentProfilesPanelSource,
apiTokenManagerSource,
organizationAccessMembersSectionSource,
organizationIncomingSharesSectionSource,
organizationOutgoingSharesSectionSource,
organizationOverviewMembersSectionSource,
rolesPanelSource,
userAssignmentsPanelSource,
]) {
expect(source).toContain('<PulseDataGrid');
expect(source).not.toContain('overflow-x-auto');
expect(source).not.toContain('-mx-4');
expect(source).not.toContain('border-x-0 sm:border-x');
}
expect(agentProfilesPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(2);
expect(apiTokenManagerSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
expect(rolesPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
expect(userAssignmentsPanelSource.match(/frame="flush"/g) ?? []).toHaveLength(1);
for (const source of [
deployCandidatesStepSource,
deployConfirmStepSource,
@@ -21,6 +21,7 @@ describe('PulseDataGrid', () => {
it('keeps the shared pulse data grid on shell, runtime, and model owners', () => {
expect(pulseDataGridSource).toContain('usePulseDataGridState');
expect(pulseDataGridSource).toContain('getPulseDataGridAlignClass');
expect(pulseDataGridSource).toContain('getPulseDataGridFrameClass');
expect(pulseDataGridSource).toContain('getPulseDataGridWidthAttr');
expect(pulseDataGridSource).toContain('isPulseDataGridInteractiveTarget');
expect(pulseDataGridSource).toContain("from '@/components/shared/Table'");
@@ -41,6 +42,13 @@ describe('PulseDataGrid', () => {
expect(pulseDataGridStateSource).toContain('reconcile(');
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridAlignClass');
expect(pulseDataGridModelSource).toContain(
"export type PulseDataGridFrame = 'default' | 'flush'",
);
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridFrameClass');
expect(pulseDataGridModelSource).toContain(
"flush: 'overflow-hidden rounded-none border-0 bg-surface'",
);
expect(pulseDataGridModelSource).toContain('export const getPulseDataGridWidthAttr');
expect(pulseDataGridModelSource).toContain('export const isPulseDataGridInteractiveTarget');
expect(pulseDataGridModelSource).toContain('target.closest(');
@@ -114,6 +122,25 @@ describe('PulseDataGrid', () => {
expect(onRowClick).not.toHaveBeenCalled();
});
it('applies the shared flush frame for grids embedded inside existing panels', () => {
render(() => (
<PulseDataGrid<TestRow>
data={[{ id: '1', name: 'Tower' }]}
columns={[{ key: 'name', label: 'Name' }]}
keyExtractor={(row) => row.id}
frame="flush"
/>
));
const table = screen.getByRole('table');
const frame = table.parentElement?.parentElement;
expect(frame).not.toBeNull();
expect(frame).toHaveClass('rounded-none');
expect(frame).toHaveClass('border-0');
expect(frame).not.toHaveClass('rounded-md');
});
it('keeps the same row DOM node when data refreshes with the same key', () => {
const [rows, setRows] = createSignal<TestRow[]>([{ id: '1', name: 'Tower' }]);
@@ -52,13 +52,26 @@ export interface PulseDataGridProps<T> {
/** Custom classes applied to the root container */
class?: string;
/** Root frame treatment. Use flush when embedding directly inside an existing panel/card frame. */
frame?: PulseDataGridFrame;
}
export type PulseDataGridFrame = 'default' | 'flush';
const PULSE_DATA_GRID_FRAME_CLASS: Record<PulseDataGridFrame, string> = {
default: 'overflow-hidden rounded-md border border-border bg-surface',
flush: 'overflow-hidden rounded-none border-0 bg-surface',
};
export type PulseDataGridStableRow<T> = {
__pulseKey: string | number;
value: T;
};
export const getPulseDataGridFrameClass = (frame?: PulseDataGridFrame) =>
PULSE_DATA_GRID_FRAME_CLASS[frame ?? 'default'];
export const getPulseDataGridAlignClass = (align?: 'left' | 'center' | 'right') => {
switch (align) {
case 'center':