Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,12 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
<SettingsSection label='Permissions'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-[var(--text-body)] text-sm'>Allow personal API keys</span>
<label
htmlFor='allow-personal-api-keys'
className='text-[var(--text-body)] text-sm'
>
Allow personal API keys
</label>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
Expand All @@ -337,6 +342,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
</div>
{isLoadingSettings ? null : (
<Switch
id='allow-personal-api-keys'
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
onCheckedChange={async (checked) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
return (
<SettingsResourceRow
key={provider.id}
ariaLabel={provider.name}
icon={<Icon />}
title={provider.name}
description={provider.description}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export function RecentlyDeleted() {
return (
<SettingsResourceRow
key={resource.id}
ariaLabel={resource.name}
icon={<ResourceIcon resource={resource} />}
title={resource.name}
description={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ function NewWorkspaceVariableRow({
return (
<div className='contents'>
<ChipInput
aria-label='New workspace secret name'
data-input-type='key'
error={Boolean(keyError)}
value={envVar.key}
Expand All @@ -286,6 +287,7 @@ function NewWorkspaceVariableRow({
/>
<div />
<SecretValueField
aria-label='New workspace secret value'
data-input-type='value'
value={envVar.value}
onChange={(next) => onUpdate(index, 'value', next)}
Expand Down Expand Up @@ -982,8 +984,13 @@ export function SecretsManager() {
{(!searchTerm.trim() ||
filteredWorkspaceEntries.length > 0 ||
filteredNewWorkspaceRows.length > 0) && (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Workspace</span>
<section aria-labelledby='workspace-secrets-section' className='flex flex-col'>
<span
id='workspace-secrets-section'
className='pl-0.5 text-[var(--text-muted)] text-small'
>
Workspace
</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className={`${GRID_COLS} gap-y-2`}>
{(searchTerm.trim()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { cn } from '@sim/emcn'
* contains to 20px, so callers pass their raw icon node without pre-sizing it.
*/
interface SettingsResourceRowProps {
/** Optional accessible name for consumers that need to scope actions to this row. */
ariaLabel?: string
/** Icon node centered in the tile; a `<svg>` is normalized to 20px, an `<img>` to 20px (or the full tile when `iconFill`). */
icon: ReactNode
/**
Expand All @@ -32,14 +34,19 @@ const TILE_BASE =
'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--bg)] [&_svg]:size-5'

export function SettingsResourceRow({
ariaLabel,
icon,
iconFill = false,
title,
description,
trailing,
}: SettingsResourceRowProps) {
return (
<div className='flex items-center justify-between gap-2.5'>
<div
role={ariaLabel ? 'group' : undefined}
aria-label={ariaLabel}
className='flex items-center justify-between gap-2.5'
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className={cn(TILE_BASE, iconFill ? '[&_img]:size-full' : '[&_img]:size-5')}>
{icon}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function SettingsSection({
children,
}: SettingsSectionProps) {
return (
<section className='flex flex-col'>
<section aria-label={label} className='flex flex-col'>
<div className='flex items-center gap-1.5 pl-0.5'>
<span className='text-[var(--text-muted)] text-small'>{label}</span>
{headerAccessory}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function TeamManagement({
organizationId,
billingHref = `/organization/${organizationId}/settings/billing`,
}: TeamManagementProps) {
const { data: session } = useSession()
const { data: session, isPending: isSessionPending } = useSession()
const { isInvitationsDisabled } = usePermissionConfig()

const { data: userSubscriptionData } = useSubscriptionData()
Expand Down Expand Up @@ -306,43 +306,49 @@ export function TeamManagement({

return (
<>
<SettingsPanel
actions={
adminOrOwner
? [
{
text: 'Invite',
icon: Plus,
variant: 'primary',
onSelect: () => setInviteModalOpen(true),
disabled: isInvitationsDisabled,
tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
},
]
: []
}
<section
aria-label='Organization members'
aria-busy={isSessionPending || isLoading || isLoadingRoster}
className='flex flex-col gap-7'
>
{adminOrOwner && (
<TeamSeatsOverview
billingHref={billingHref}
subscriptionData={orgSubscription}
isLoadingSubscription={isOrgBillingLoading}
totalSeats={totalSeats}
usedSeats={usedSeats}
pendingSeats={pendingSeats}
<SettingsPanel
actions={
adminOrOwner
? [
{
text: 'Invite',
icon: Plus,
variant: 'primary',
onSelect: () => setInviteModalOpen(true),
disabled: isInvitationsDisabled,
tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
},
]
: []
}
>
{adminOrOwner && (
<TeamSeatsOverview
billingHref={billingHref}
subscriptionData={orgSubscription}
isLoadingSubscription={isOrgBillingLoading}
totalSeats={totalSeats}
usedSeats={usedSeats}
pendingSeats={pendingSeats}
/>
)}

<OrganizationMemberLists
canManage={adminOrOwner}
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
)}

<OrganizationMemberLists
canManage={adminOrOwner}
organizationId={displayOrganization.id}
roster={roster ?? null}
isLoadingRoster={isLoadingRoster}
currentUserId={session?.user?.id ?? ''}
onRemoveMember={handleRemoveMember}
onTransferOwnership={handleOpenTransferDialog}
/>
</SettingsPanel>
</SettingsPanel>
</section>

{adminOrOwner && (
<OrganizationInviteModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isHosted } from '@/lib/core/config/env-flags'
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
import {
allNavigationItems,
Expand Down Expand Up @@ -73,7 +73,13 @@ export function SettingsSidebar({

const { config: permissionConfig } = usePermissionConfig()
const forkingAvailable = useForkingAvailable(workspaceId)
const { canAdmin: canAdminWorkspace } = useUserPermissionsContext()
const { userPermissions } = useWorkspacePermissionsContext()
const { canAdmin: canAdminWorkspace } = userPermissions
const authorizationState = userPermissions.isLoading
? 'loading'
: userPermissions.canRead
? 'granted'
: 'denied'

const userId = session?.user?.id

Expand Down Expand Up @@ -288,6 +294,8 @@ export function SettingsSidebar({
<div
role='navigation'
aria-label='Workspace settings sections'
aria-busy={authorizationState === 'loading'}
data-authorization-state={authorizationState}
ref={isCollapsed ? undefined : scrollContainerRef}
className={cn(
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-1.5 transition-colors duration-150',
Expand Down
48 changes: 41 additions & 7 deletions apps/sim/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ bun run test:e2e -- --grep "unauthenticated"
```

Projects form a dependency chain to keep shared boundaries serialized. Selecting
the personas project therefore also runs navigation and workflows by default,
and an upstream failure skips its dependents. For focused local iteration,
`--no-deps` is explicitly supported:
the personas project therefore also runs navigation, authorization, and
workflows by default, and an upstream failure skips its dependents. For focused
local iteration, `--no-deps` is explicitly supported:

```bash
bun run test:e2e -- --project=hosted-billing-chromium-personas --no-deps
Expand Down Expand Up @@ -131,6 +131,40 @@ That override fails closed unless the exact hosted E2E profile, E2E auth origin,
and loopback `sim_e2e_*` database are all present; normal deployments retain
Better Auth's defaults.

## Settings authorization contracts

Step 4 owns literal direct-route access, entitlement, and mutation-control
datasets in `e2e/settings/authorization/contracts.ts`. Existing Step 3
navigation positives are referenced by stable contract IDs instead of rerun.
The browser specs cover the remaining account, organization, and workspace
denials, paid Billing readiness, role-scoped mutation chrome, and shared
unsaved-change behavior.

Run only these contracts during local iteration:

```bash
bun run test:e2e -- --reuse-build \
--project=hosted-billing-chromium-authorization --no-deps \
e2e/settings/authorization
```

The hosted profile enables Custom blocks with `DEPLOY_AS_BLOCK=true` only for
the Next.js build and app processes; migration, seed, auth capture, Playwright,
and realtime never receive the flag. The strict Stripe fake implements only the
invoice-list shape used by paid settings pages: an existing fake customer,
`limit=20`, `expand[0]=data.lines`, and an optional cursor. It returns an empty
Stripe list and rejects all extra or malformed request shapes.

Unsaved-change coverage intentionally stops at settings sidebar navigation,
the app's Settings Back action, and native `beforeunload`. Toolbar history and
credential, fork, or custom-block detail guards belong to later roadmap steps.
On the Step 4 reference run, two workers completed all 84 retry-free
authorization tests in 1.2 minutes and the cache-hit orchestrator in 4 minutes
42 seconds. The final dependency chain passed all 223 navigation,
authorization, workflow, persona, and isolation tests in 2.4 minutes of
Playwright time; the clean-build orchestrator completed in 8 minutes 25
seconds.

The cache lives under ignored `e2e/.cache/builds/`. A hit requires matching
source contents (including uncommitted/untracked files), build/public profile,
Node/Bun/Next versions, platform, `BUILD_ID`, and the cached artifact checksum.
Expand All @@ -153,10 +187,10 @@ were not launched by the orchestrator. Report and trace viewer commands remain
safe because they do not execute tests.

Sharding is supported only for the navigation project. The runner rejects
`--shard` for workflows, persona contracts, and the dedicated two-worker
cross-world isolation project. Project dependencies serialize navigation,
workflows, and persona contracts before the isolation project opens its
two-worker pool.
`--shard` for authorization, workflows, persona contracts, and the dedicated
two-worker cross-world isolation project. Project dependencies serialize
navigation, authorization, workflows, and persona contracts before the
isolation project opens its two-worker pool.

## Diagnostics

Expand Down
53 changes: 53 additions & 0 deletions apps/sim/e2e/fakes/stripe/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function isExpectedStripeRequest(method: string, path: string): boolean {
((method === 'GET' || method === 'POST') && path === '/v1/customers/search') ||
(method === 'GET' && path === '/v1/customers') ||
(method === 'POST' && path === '/v1/customers') ||
(method === 'GET' && path === '/v1/invoices') ||
(method === 'POST' && path === STRIPE_FAKE_ENDPOINTS.telemetry) ||
(method === 'GET' && /^\/v1\/customers\/cus_e2e_[a-f0-9]+$/.test(path))
)
Expand Down Expand Up @@ -196,6 +197,42 @@ function parseLimit(parameters: URLSearchParams): number {
return limit
}

function assertSupportedInvoiceList(
parameters: URLSearchParams,
customers: ReadonlyMap<string, FakeCustomer>
): void {
const allowedKeys = new Set(['customer', 'limit', 'expand[0]', 'starting_after'])
for (const key of new Set(parameters.keys())) {
if (!allowedKeys.has(key)) {
throw new RequestBodyError(`Stripe fake does not implement invoice parameter: ${key}`, 501)
}
}

const customerValues = parameters.getAll('customer')
const limitValues = parameters.getAll('limit')
const expandValues = parameters.getAll('expand[0]')
const cursorValues = parameters.getAll('starting_after')
if (
customerValues.length !== 1 ||
limitValues.length !== 1 ||
expandValues.length !== 1 ||
cursorValues.length > 1
) {
throw new RequestBodyError('Stripe fake requires one value for each invoice parameter', 501)
}

const customerId = customerValues[0]
if (!customerId || !customers.has(customerId)) {
throw new RequestBodyError(`Stripe fake does not know invoice customer: ${customerId}`, 501)
}
if (limitValues[0] !== '20' || expandValues[0] !== 'data.lines') {
throw new RequestBodyError('Stripe fake received an unsupported invoice list shape', 501)
}
if (cursorValues.length === 1 && !cursorValues[0]) {
throw new RequestBodyError('Stripe fake requires a non-empty invoice cursor', 501)
}
}

function validateTestApiKey(apiKey: string): void {
if (!apiKey.startsWith('sk_test_') || apiKey.length === 'sk_test_'.length) {
throw new Error('Stripe fake apiKey must be a non-empty sk_test_ secret key')
Expand Down Expand Up @@ -410,6 +447,22 @@ export function createStripeFakeServer(options: StripeFakeServerOptions): Stripe
return
}

if (method === 'GET' && url.pathname === '/v1/invoices') {
assertSupportedInvoiceList(url.searchParams, customers)
sendJson(
response,
200,
{
object: 'list',
data: [],
has_more: false,
url: '/v1/invoices',
},
requestId
)
return
}

if (method === 'GET' && url.pathname.startsWith('/v1/customers/')) {
const customerId = decodeURIComponent(url.pathname.slice('/v1/customers/'.length))
const customer = customers.get(customerId)
Expand Down
Loading
Loading