Skip to content

Commit 029f501

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragent
authored
Add settings authorization acceptance contracts (#5843)
* Add settings authorization acceptance contracts Co-authored-by: Cursor <cursoragent@cursor.com> * Clarify authorization readiness states Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 040963e commit 029f501

24 files changed

Lines changed: 2140 additions & 77 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/api-keys/api-keys.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,12 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
318318
<SettingsSection label='Permissions'>
319319
<div className='flex items-center justify-between'>
320320
<div className='flex items-center gap-2'>
321-
<span className='text-[var(--text-body)] text-sm'>Allow personal API keys</span>
321+
<label
322+
htmlFor='allow-personal-api-keys'
323+
className='text-[var(--text-body)] text-sm'
324+
>
325+
Allow personal API keys
326+
</label>
322327
<Tooltip.Root>
323328
<Tooltip.Trigger asChild>
324329
<button
@@ -337,6 +342,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
337342
</div>
338343
{isLoadingSettings ? null : (
339344
<Switch
345+
id='allow-personal-api-keys'
340346
checked={allowPersonalApiKeys}
341347
disabled={!canManageWorkspaceKeys || updateSettingsMutation.isPending}
342348
onCheckedChange={async (checked) => {

apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
287287
return (
288288
<SettingsResourceRow
289289
key={provider.id}
290+
ariaLabel={provider.name}
290291
icon={<Icon />}
291292
title={provider.name}
292293
description={provider.description}

apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,7 @@ export function RecentlyDeleted() {
423423
return (
424424
<SettingsResourceRow
425425
key={resource.id}
426+
ariaLabel={resource.name}
426427
icon={<ResourceIcon resource={resource} />}
427428
title={resource.name}
428429
description={

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ function NewWorkspaceVariableRow({
271271
return (
272272
<div className='contents'>
273273
<ChipInput
274+
aria-label='New workspace secret name'
274275
data-input-type='key'
275276
error={Boolean(keyError)}
276277
value={envVar.key}
@@ -286,6 +287,7 @@ function NewWorkspaceVariableRow({
286287
/>
287288
<div />
288289
<SecretValueField
290+
aria-label='New workspace secret value'
289291
data-input-type='value'
290292
value={envVar.value}
291293
onChange={(next) => onUpdate(index, 'value', next)}
@@ -982,8 +984,13 @@ export function SecretsManager() {
982984
{(!searchTerm.trim() ||
983985
filteredWorkspaceEntries.length > 0 ||
984986
filteredNewWorkspaceRows.length > 0) && (
985-
<section className='flex flex-col'>
986-
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Workspace</span>
987+
<section aria-labelledby='workspace-secrets-section' className='flex flex-col'>
988+
<span
989+
id='workspace-secrets-section'
990+
className='pl-0.5 text-[var(--text-muted)] text-small'
991+
>
992+
Workspace
993+
</span>
987994
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
988995
<div className={`${GRID_COLS} gap-y-2`}>
989996
{(searchTerm.trim()

apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { cn } from '@sim/emcn'
1212
* contains to 20px, so callers pass their raw icon node without pre-sizing it.
1313
*/
1414
interface SettingsResourceRowProps {
15+
/** Optional accessible name for consumers that need to scope actions to this row. */
16+
ariaLabel?: string
1517
/** Icon node centered in the tile; a `<svg>` is normalized to 20px, an `<img>` to 20px (or the full tile when `iconFill`). */
1618
icon: ReactNode
1719
/**
@@ -32,14 +34,19 @@ const TILE_BASE =
3234
'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'
3335

3436
export function SettingsResourceRow({
37+
ariaLabel,
3538
icon,
3639
iconFill = false,
3740
title,
3841
description,
3942
trailing,
4043
}: SettingsResourceRowProps) {
4144
return (
42-
<div className='flex items-center justify-between gap-2.5'>
45+
<div
46+
role={ariaLabel ? 'group' : undefined}
47+
aria-label={ariaLabel}
48+
className='flex items-center justify-between gap-2.5'
49+
>
4350
<div className='flex min-w-0 items-center gap-2.5'>
4451
<div className={cn(TILE_BASE, iconFill ? '[&_img]:size-full' : '[&_img]:size-5')}>
4552
{icon}

apps/sim/app/workspace/[workspaceId]/settings/components/settings-section/settings-section.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function SettingsSection({
2020
children,
2121
}: SettingsSectionProps) {
2222
return (
23-
<section className='flex flex-col'>
23+
<section aria-label={label} className='flex flex-col'>
2424
<div className='flex items-center gap-1.5 pl-0.5'>
2525
<span className='text-[var(--text-muted)] text-small'>{label}</span>
2626
{headerAccessory}

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function TeamManagement({
3838
organizationId,
3939
billingHref = `/organization/${organizationId}/settings/billing`,
4040
}: TeamManagementProps) {
41-
const { data: session } = useSession()
41+
const { data: session, isPending: isSessionPending } = useSession()
4242
const { isInvitationsDisabled } = usePermissionConfig()
4343

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

307307
return (
308308
<>
309-
<SettingsPanel
310-
actions={
311-
adminOrOwner
312-
? [
313-
{
314-
text: 'Invite',
315-
icon: Plus,
316-
variant: 'primary',
317-
onSelect: () => setInviteModalOpen(true),
318-
disabled: isInvitationsDisabled,
319-
tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
320-
},
321-
]
322-
: []
323-
}
309+
<section
310+
aria-label='Organization members'
311+
aria-busy={isSessionPending || isLoading || isLoadingRoster}
312+
className='flex flex-col gap-7'
324313
>
325-
{adminOrOwner && (
326-
<TeamSeatsOverview
327-
billingHref={billingHref}
328-
subscriptionData={orgSubscription}
329-
isLoadingSubscription={isOrgBillingLoading}
330-
totalSeats={totalSeats}
331-
usedSeats={usedSeats}
332-
pendingSeats={pendingSeats}
314+
<SettingsPanel
315+
actions={
316+
adminOrOwner
317+
? [
318+
{
319+
text: 'Invite',
320+
icon: Plus,
321+
variant: 'primary',
322+
onSelect: () => setInviteModalOpen(true),
323+
disabled: isInvitationsDisabled,
324+
tooltip: isInvitationsDisabled ? 'Invitations are disabled' : undefined,
325+
},
326+
]
327+
: []
328+
}
329+
>
330+
{adminOrOwner && (
331+
<TeamSeatsOverview
332+
billingHref={billingHref}
333+
subscriptionData={orgSubscription}
334+
isLoadingSubscription={isOrgBillingLoading}
335+
totalSeats={totalSeats}
336+
usedSeats={usedSeats}
337+
pendingSeats={pendingSeats}
338+
/>
339+
)}
340+
341+
<OrganizationMemberLists
342+
canManage={adminOrOwner}
343+
organizationId={displayOrganization.id}
344+
roster={roster ?? null}
345+
isLoadingRoster={isLoadingRoster}
346+
currentUserId={session?.user?.id ?? ''}
347+
onRemoveMember={handleRemoveMember}
348+
onTransferOwnership={handleOpenTransferDialog}
333349
/>
334-
)}
335-
336-
<OrganizationMemberLists
337-
canManage={adminOrOwner}
338-
organizationId={displayOrganization.id}
339-
roster={roster ?? null}
340-
isLoadingRoster={isLoadingRoster}
341-
currentUserId={session?.user?.id ?? ''}
342-
onRemoveMember={handleRemoveMember}
343-
onTransferOwnership={handleOpenTransferDialog}
344-
/>
345-
</SettingsPanel>
350+
</SettingsPanel>
351+
</section>
346352

347353
{adminOrOwner && (
348354
<OrganizationInviteModal

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client'
1010
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
1111
import { isHosted } from '@/lib/core/config/env-flags'
1212
import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
13-
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
13+
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1414
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
1515
import {
1616
allNavigationItems,
@@ -73,7 +73,13 @@ export function SettingsSidebar({
7373

7474
const { config: permissionConfig } = usePermissionConfig()
7575
const forkingAvailable = useForkingAvailable(workspaceId)
76-
const { canAdmin: canAdminWorkspace } = useUserPermissionsContext()
76+
const { userPermissions } = useWorkspacePermissionsContext()
77+
const { canAdmin: canAdminWorkspace } = userPermissions
78+
const authorizationState = userPermissions.isLoading
79+
? 'loading'
80+
: userPermissions.canRead
81+
? 'granted'
82+
: 'denied'
7783

7884
const userId = session?.user?.id
7985

@@ -288,6 +294,8 @@ export function SettingsSidebar({
288294
<div
289295
role='navigation'
290296
aria-label='Workspace settings sections'
297+
aria-busy={authorizationState === 'loading'}
298+
data-authorization-state={authorizationState}
291299
ref={isCollapsed ? undefined : scrollContainerRef}
292300
className={cn(
293301
'flex flex-1 flex-col overflow-y-auto overflow-x-hidden border-t pt-1.5 transition-colors duration-150',

apps/sim/e2e/README.md

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ bun run test:e2e -- --grep "unauthenticated"
8080
```
8181

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

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

134+
## Settings authorization contracts
135+
136+
Step 4 owns literal direct-route access, entitlement, and mutation-control
137+
datasets in `e2e/settings/authorization/contracts.ts`. Existing Step 3
138+
navigation positives are referenced by stable contract IDs instead of rerun.
139+
The browser specs cover the remaining account, organization, and workspace
140+
denials, paid Billing readiness, role-scoped mutation chrome, and shared
141+
unsaved-change behavior.
142+
143+
Run only these contracts during local iteration:
144+
145+
```bash
146+
bun run test:e2e -- --reuse-build \
147+
--project=hosted-billing-chromium-authorization --no-deps \
148+
e2e/settings/authorization
149+
```
150+
151+
The hosted profile enables Custom blocks with `DEPLOY_AS_BLOCK=true` only for
152+
the Next.js build and app processes; migration, seed, auth capture, Playwright,
153+
and realtime never receive the flag. The strict Stripe fake implements only the
154+
invoice-list shape used by paid settings pages: an existing fake customer,
155+
`limit=20`, `expand[0]=data.lines`, and an optional cursor. It returns an empty
156+
Stripe list and rejects all extra or malformed request shapes.
157+
158+
Unsaved-change coverage intentionally stops at settings sidebar navigation,
159+
the app's Settings Back action, and native `beforeunload`. Toolbar history and
160+
credential, fork, or custom-block detail guards belong to later roadmap steps.
161+
On the Step 4 reference run, two workers completed all 84 retry-free
162+
authorization tests in 1.2 minutes and the cache-hit orchestrator in 4 minutes
163+
42 seconds. The final dependency chain passed all 223 navigation,
164+
authorization, workflow, persona, and isolation tests in 2.4 minutes of
165+
Playwright time; the clean-build orchestrator completed in 8 minutes 25
166+
seconds.
167+
134168
The cache lives under ignored `e2e/.cache/builds/`. A hit requires matching
135169
source contents (including uncommitted/untracked files), build/public profile,
136170
Node/Bun/Next versions, platform, `BUILD_ID`, and the cached artifact checksum.
@@ -153,10 +187,10 @@ were not launched by the orchestrator. Report and trace viewer commands remain
153187
safe because they do not execute tests.
154188

155189
Sharding is supported only for the navigation project. The runner rejects
156-
`--shard` for workflows, persona contracts, and the dedicated two-worker
157-
cross-world isolation project. Project dependencies serialize navigation,
158-
workflows, and persona contracts before the isolation project opens its
159-
two-worker pool.
190+
`--shard` for authorization, workflows, persona contracts, and the dedicated
191+
two-worker cross-world isolation project. Project dependencies serialize
192+
navigation, authorization, workflows, and persona contracts before the
193+
isolation project opens its two-worker pool.
160194

161195
## Diagnostics
162196

apps/sim/e2e/fakes/stripe/server.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ function isExpectedStripeRequest(method: string, path: string): boolean {
6565
((method === 'GET' || method === 'POST') && path === '/v1/customers/search') ||
6666
(method === 'GET' && path === '/v1/customers') ||
6767
(method === 'POST' && path === '/v1/customers') ||
68+
(method === 'GET' && path === '/v1/invoices') ||
6869
(method === 'POST' && path === STRIPE_FAKE_ENDPOINTS.telemetry) ||
6970
(method === 'GET' && /^\/v1\/customers\/cus_e2e_[a-f0-9]+$/.test(path))
7071
)
@@ -196,6 +197,42 @@ function parseLimit(parameters: URLSearchParams): number {
196197
return limit
197198
}
198199

200+
function assertSupportedInvoiceList(
201+
parameters: URLSearchParams,
202+
customers: ReadonlyMap<string, FakeCustomer>
203+
): void {
204+
const allowedKeys = new Set(['customer', 'limit', 'expand[0]', 'starting_after'])
205+
for (const key of new Set(parameters.keys())) {
206+
if (!allowedKeys.has(key)) {
207+
throw new RequestBodyError(`Stripe fake does not implement invoice parameter: ${key}`, 501)
208+
}
209+
}
210+
211+
const customerValues = parameters.getAll('customer')
212+
const limitValues = parameters.getAll('limit')
213+
const expandValues = parameters.getAll('expand[0]')
214+
const cursorValues = parameters.getAll('starting_after')
215+
if (
216+
customerValues.length !== 1 ||
217+
limitValues.length !== 1 ||
218+
expandValues.length !== 1 ||
219+
cursorValues.length > 1
220+
) {
221+
throw new RequestBodyError('Stripe fake requires one value for each invoice parameter', 501)
222+
}
223+
224+
const customerId = customerValues[0]
225+
if (!customerId || !customers.has(customerId)) {
226+
throw new RequestBodyError(`Stripe fake does not know invoice customer: ${customerId}`, 501)
227+
}
228+
if (limitValues[0] !== '20' || expandValues[0] !== 'data.lines') {
229+
throw new RequestBodyError('Stripe fake received an unsupported invoice list shape', 501)
230+
}
231+
if (cursorValues.length === 1 && !cursorValues[0]) {
232+
throw new RequestBodyError('Stripe fake requires a non-empty invoice cursor', 501)
233+
}
234+
}
235+
199236
function validateTestApiKey(apiKey: string): void {
200237
if (!apiKey.startsWith('sk_test_') || apiKey.length === 'sk_test_'.length) {
201238
throw new Error('Stripe fake apiKey must be a non-empty sk_test_ secret key')
@@ -410,6 +447,22 @@ export function createStripeFakeServer(options: StripeFakeServerOptions): Stripe
410447
return
411448
}
412449

450+
if (method === 'GET' && url.pathname === '/v1/invoices') {
451+
assertSupportedInvoiceList(url.searchParams, customers)
452+
sendJson(
453+
response,
454+
200,
455+
{
456+
object: 'list',
457+
data: [],
458+
has_more: false,
459+
url: '/v1/invoices',
460+
},
461+
requestId
462+
)
463+
return
464+
}
465+
413466
if (method === 'GET' && url.pathname.startsWith('/v1/customers/')) {
414467
const customerId = decodeURIComponent(url.pathname.slice('/v1/customers/'.length))
415468
const customer = customers.get(customerId)

0 commit comments

Comments
 (0)