mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
improvement(access-controls): ui/ux improvements (#5190)
* improvement(access-controls): ui/ux improvements * remove unused col
This commit is contained in:
@@ -145,7 +145,7 @@ A workspace-scoped group applies to **all members of its workspaces by default**
|
||||
|
||||
A user is governed by one group per workspace, so adding a user is rejected when it would conflict with another of their groups on a shared workspace (skipped rather than added in bulk). The default group ignores members entirely — it always governs everyone not covered by a workspace group.
|
||||
|
||||
Manage which workspaces a group governs from the **Workspaces** list in the group's **Details** view (Add and Remove). A non-default group must always target at least one workspace.
|
||||
Manage which workspaces a group governs from the **Workspaces** list in the group's **Details** view (Add and Remove). A non-default group is created targeting at least one workspace, but you can later remove all of them — a group with no workspaces simply governs nothing until you add one back.
|
||||
|
||||
External workspace members (people who have access to a workspace but belong to a different organization) can't be added as named members, but a workspace-scoped group with no members — and the organization default group — still governs them.
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ export const DELETE = withRouteHandler(
|
||||
throw new Error('MEMBER_NOT_FOUND')
|
||||
}
|
||||
|
||||
if (!lockedGroup.isDefault && !lockedGroup.appliesToAllWorkspaces) {
|
||||
if (!lockedGroup.isDefault) {
|
||||
const [memberCountRow] = await tx
|
||||
.select({ value: count() })
|
||||
.from(permissionGroupMember)
|
||||
|
||||
@@ -48,7 +48,7 @@ export const GET = withRouteHandler(
|
||||
return NextResponse.json({ error: 'Permission group not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaces = group.appliesToAllWorkspaces ? [] : await getGroupWorkspaces(id)
|
||||
const workspaces = group.isDefault ? [] : await getGroupWorkspaces(id)
|
||||
|
||||
return NextResponse.json({
|
||||
permissionGroup: {
|
||||
@@ -117,44 +117,30 @@ export const PUT = withRouteHandler(
|
||||
|
||||
// Demoting the org default with no new scope: it becomes a non-default
|
||||
// group with no workspaces (inert) until an admin re-scopes it. The client
|
||||
// sends only `isDefault: false`, so this never forwards a workspace list
|
||||
// (which a non-default group otherwise requires) against the per-group cap.
|
||||
// sends only `isDefault: false`, so this never forwards a workspace list.
|
||||
const demotingDefaultToInert =
|
||||
group.isDefault &&
|
||||
updates.isDefault === false &&
|
||||
updates.appliesToAllWorkspaces === undefined &&
|
||||
updates.workspaceIds === undefined
|
||||
|
||||
// Resolve the target workspace scope. Setting the group as default forces
|
||||
// all-workspaces; otherwise an explicit `appliesToAllWorkspaces` wins, and
|
||||
// supplying `workspaceIds` alone implies a specific scope.
|
||||
const scopeProvided =
|
||||
demotingDefaultToInert ||
|
||||
updates.appliesToAllWorkspaces !== undefined ||
|
||||
updates.workspaceIds !== undefined ||
|
||||
updates.isDefault === true
|
||||
|
||||
const resolvedAppliesToAll = demotingDefaultToInert
|
||||
? false
|
||||
: updates.isDefault === true
|
||||
? true
|
||||
: updates.appliesToAllWorkspaces !== undefined
|
||||
? updates.appliesToAllWorkspaces
|
||||
: updates.workspaceIds !== undefined
|
||||
? false
|
||||
: group.appliesToAllWorkspaces
|
||||
group.isDefault && updates.isDefault === false && updates.workspaceIds === undefined
|
||||
|
||||
// "Org-wide" is definitionally `isDefault` (the default group), so the
|
||||
// effective scope follows it: a default group targets no specific
|
||||
// workspaces; a non-default group targets its `workspaceIds`.
|
||||
const effectiveIsDefault =
|
||||
updates.isDefault !== undefined ? updates.isDefault : group.isDefault
|
||||
if (effectiveIsDefault && !resolvedAppliesToAll) {
|
||||
|
||||
// Scope is rewritten when the group is promoted to default, demoted to
|
||||
// inert, or handed an explicit workspace list.
|
||||
const scopeProvided =
|
||||
demotingDefaultToInert || updates.workspaceIds !== undefined || updates.isDefault === true
|
||||
|
||||
// The default group governs every workspace, so it can't also name specific
|
||||
// ones. The contract rejects `isDefault: true` + workspaceIds, but a direct
|
||||
// API caller can still send workspaceIds against a group that is already the
|
||||
// default — reject rather than silently dropping them.
|
||||
if (effectiveIsDefault && updates.workspaceIds !== undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: 'The default group must apply to all workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (!effectiveIsDefault && resolvedAppliesToAll) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Non-default groups must target specific workspaces' },
|
||||
{
|
||||
error: 'The default group governs all workspaces and cannot target specific workspaces',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -164,14 +150,11 @@ export const PUT = withRouteHandler(
|
||||
// ("keep current"), they're read under the lock instead (see below) so the
|
||||
// conflict check and the write share one consistent snapshot.
|
||||
let providedWorkspaceIds: string[] | null = null
|
||||
if (!resolvedAppliesToAll && updates.workspaceIds !== undefined) {
|
||||
if (!effectiveIsDefault && updates.workspaceIds !== undefined) {
|
||||
// Zero workspaces is allowed on update: the group then governs nothing
|
||||
// (the resolver inner-joins on the link table, so an empty group never
|
||||
// matches any workspace). No "at least one" floor here.
|
||||
providedWorkspaceIds = Array.from(new Set(updates.workspaceIds))
|
||||
if (providedWorkspaceIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one workspace when the group targets specific workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const invalid = await findWorkspacesNotInOrganization(providedWorkspaceIds, organizationId)
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
@@ -197,12 +180,12 @@ export const PUT = withRouteHandler(
|
||||
if (scopeProvided) {
|
||||
await acquirePermissionGroupOrgLock(tx, organizationId)
|
||||
|
||||
if (!resolvedAppliesToAll) {
|
||||
if (!effectiveIsDefault) {
|
||||
// May resolve to an empty list — a non-default group is allowed to
|
||||
// target zero workspaces (governs nothing). The write below deletes
|
||||
// the old links and inserts none.
|
||||
resolvedWorkspaceIds =
|
||||
providedWorkspaceIds ?? (await getGroupWorkspaces(id, tx)).map((ws) => ws.id)
|
||||
if (resolvedWorkspaceIds.length === 0 && !demotingDefaultToInert) {
|
||||
throw new Error('NO_WORKSPACES')
|
||||
}
|
||||
}
|
||||
|
||||
const members = await tx
|
||||
@@ -225,7 +208,7 @@ export const PUT = withRouteHandler(
|
||||
|
||||
// With no explicit members the group governs all members of its
|
||||
// workspaces; reject when another all-members group already does.
|
||||
if (!resolvedAppliesToAll && members.length === 0) {
|
||||
if (!effectiveIsDefault && members.length === 0) {
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: id, workspaceIds: resolvedWorkspaceIds },
|
||||
tx
|
||||
@@ -238,12 +221,12 @@ export const PUT = withRouteHandler(
|
||||
}
|
||||
|
||||
if (updates.isDefault === true) {
|
||||
// Demote the prior default to a non-default group. It must also drop
|
||||
// the all-workspaces scope (only the default may be org-wide); it ends
|
||||
// up with no workspaces (inert) until an admin re-scopes it.
|
||||
// Demote the prior default to a non-default group (only the default may
|
||||
// be org-wide); it ends up with no workspaces (inert) until an admin
|
||||
// re-scopes it.
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ isDefault: false, appliesToAllWorkspaces: false, updatedAt: now })
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
@@ -258,7 +241,6 @@ export const PUT = withRouteHandler(
|
||||
...(updates.name !== undefined && { name: updates.name }),
|
||||
...(updates.description !== undefined && { description: updates.description }),
|
||||
...(updates.isDefault !== undefined && { isDefault: updates.isDefault }),
|
||||
...(scopeProvided && { appliesToAllWorkspaces: resolvedAppliesToAll }),
|
||||
config: newConfig,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -268,7 +250,7 @@ export const PUT = withRouteHandler(
|
||||
await tx
|
||||
.delete(permissionGroupWorkspace)
|
||||
.where(eq(permissionGroupWorkspace.permissionGroupId, id))
|
||||
if (!resolvedAppliesToAll && resolvedWorkspaceIds.length > 0) {
|
||||
if (!effectiveIsDefault && resolvedWorkspaceIds.length > 0) {
|
||||
await tx.insert(permissionGroupWorkspace).values(
|
||||
resolvedWorkspaceIds.map((workspaceId) => ({
|
||||
id: generateId(),
|
||||
@@ -288,7 +270,7 @@ export const PUT = withRouteHandler(
|
||||
.where(eq(permissionGroup.id, id))
|
||||
.limit(1)
|
||||
|
||||
const finalWorkspaceIds = updated.appliesToAllWorkspaces
|
||||
const finalWorkspaceIds = updated.isDefault
|
||||
? []
|
||||
: (await getGroupWorkspaces(id)).map((ws) => ws.id)
|
||||
|
||||
@@ -334,12 +316,6 @@ export const PUT = withRouteHandler(
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
if (error instanceof Error && error.message === 'NO_WORKSPACES') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one workspace when the group targets specific workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
if (getPostgresErrorCode(error) === '23505') {
|
||||
const constraint = getPostgresConstraintName(error)
|
||||
if (constraint === PERMISSION_GROUP_CONSTRAINTS.organizationName) {
|
||||
|
||||
@@ -55,7 +55,6 @@ export const GET = withRouteHandler(
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
|
||||
creatorName: user.name,
|
||||
creatorEmail: user.email,
|
||||
})
|
||||
@@ -114,21 +113,20 @@ export const POST = withRouteHandler(
|
||||
const { name, description, config, isDefault } = parsed.data.body
|
||||
|
||||
// Only the organization default group is org-wide; every other group
|
||||
// targets specific workspaces (the contract rejects all-workspaces scope
|
||||
// for non-default groups).
|
||||
const appliesToAllWorkspaces = isDefault === true
|
||||
const workspaceIds = appliesToAllWorkspaces
|
||||
// targets specific workspaces. "Org-wide" is definitionally `isDefault`.
|
||||
const isDefaultGroup = isDefault === true
|
||||
const workspaceIds = isDefaultGroup
|
||||
? []
|
||||
: Array.from(new Set(parsed.data.body.workspaceIds ?? []))
|
||||
|
||||
if (!appliesToAllWorkspaces && workspaceIds.length === 0) {
|
||||
if (!isDefaultGroup && workspaceIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Select at least one workspace when the group targets specific workspaces' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!appliesToAllWorkspaces) {
|
||||
if (!isDefaultGroup) {
|
||||
const invalid = await findWorkspacesNotInOrganization(workspaceIds, organizationId)
|
||||
if (invalid.length > 0) {
|
||||
return NextResponse.json(
|
||||
@@ -169,7 +167,6 @@ export const POST = withRouteHandler(
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDefault: isDefault || false,
|
||||
appliesToAllWorkspaces,
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -177,7 +174,7 @@ export const POST = withRouteHandler(
|
||||
|
||||
// A new non-default group has no members, so it governs all members of
|
||||
// its workspaces; reject when another all-members group already does.
|
||||
if (!appliesToAllWorkspaces) {
|
||||
if (!isDefaultGroup) {
|
||||
const conflict = await findAllMembersWorkspaceConflict(
|
||||
{ organizationId, excludeGroupId: newGroup.id, workspaceIds },
|
||||
tx
|
||||
@@ -189,12 +186,12 @@ export const POST = withRouteHandler(
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
// Demote the prior default to a non-default group. It must also drop
|
||||
// the all-workspaces scope (only the default may be org-wide); it ends
|
||||
// up with no workspaces (inert) until an admin re-scopes it.
|
||||
// Demote the prior default to a non-default group (only the default may
|
||||
// be org-wide); it ends up with no workspaces (inert) until an admin
|
||||
// re-scopes it.
|
||||
await tx
|
||||
.update(permissionGroup)
|
||||
.set({ isDefault: false, appliesToAllWorkspaces: false, updatedAt: now })
|
||||
.set({ isDefault: false, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(permissionGroup.organizationId, organizationId),
|
||||
@@ -220,7 +217,6 @@ export const POST = withRouteHandler(
|
||||
permissionGroupId: newGroup.id,
|
||||
organizationId,
|
||||
userId: session.user.id,
|
||||
appliesToAllWorkspaces,
|
||||
workspaceCount: workspaceIds.length,
|
||||
})
|
||||
|
||||
@@ -236,7 +232,6 @@ export const POST = withRouteHandler(
|
||||
metadata: {
|
||||
organizationId,
|
||||
isDefault: isDefault || false,
|
||||
appliesToAllWorkspaces,
|
||||
workspaceCount: workspaceIds.length,
|
||||
},
|
||||
request: req,
|
||||
|
||||
@@ -88,7 +88,6 @@ export async function loadGroupInOrganization(
|
||||
createdAt: permissionGroup.createdAt,
|
||||
updatedAt: permissionGroup.updatedAt,
|
||||
isDefault: permissionGroup.isDefault,
|
||||
appliesToAllWorkspaces: permissionGroup.appliesToAllWorkspaces,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
.where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId)))
|
||||
|
||||
@@ -282,7 +282,8 @@ function WorkspaceSelect({
|
||||
<ChipDropdown
|
||||
multiple
|
||||
searchable
|
||||
matchTriggerWidth={false}
|
||||
align={fullWidth ? 'start' : 'end'}
|
||||
matchTriggerWidth={fullWidth}
|
||||
options={options}
|
||||
value={workspaceIds}
|
||||
onChange={onChange}
|
||||
@@ -519,7 +520,7 @@ export function AccessControl() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [viewingGroup, setViewingGroup] = useState<PermissionGroup | null>(null)
|
||||
// Monotonic token for scope-affecting writes (workspace select + default
|
||||
// toggle, which both set appliesToAllWorkspaces/workspaces). Only the most
|
||||
// toggle, which both change the group's workspace scope). Only the most
|
||||
// recent write may reconcile or revert the local viewingGroup, so rapid
|
||||
// multi-select toggles can't settle on a stale, out-of-order response.
|
||||
const scopeWriteSeqRef = useRef(0)
|
||||
@@ -797,17 +798,15 @@ export function AccessControl() {
|
||||
const handleCreatePermissionGroup = useCallback(async () => {
|
||||
if (!newGroupName.trim() || !organizationId) return
|
||||
setCreateError(null)
|
||||
// Only the default group is organization-wide; every other group targets
|
||||
// specific workspaces.
|
||||
const appliesToAllWorkspaces = newGroupIsDefault
|
||||
try {
|
||||
await createPermissionGroup.mutateAsync({
|
||||
organizationId,
|
||||
name: newGroupName.trim(),
|
||||
description: newGroupDescription.trim() || undefined,
|
||||
isDefault: newGroupIsDefault,
|
||||
appliesToAllWorkspaces,
|
||||
workspaceIds: appliesToAllWorkspaces ? undefined : newGroupWorkspaceIds,
|
||||
// Only the default group is organization-wide; every other group targets
|
||||
// specific workspaces (omitted for the default group).
|
||||
workspaceIds: newGroupIsDefault ? undefined : newGroupWorkspaceIds,
|
||||
})
|
||||
setShowCreateModal(false)
|
||||
setNewGroupName('')
|
||||
@@ -970,12 +969,9 @@ export function AccessControl() {
|
||||
const handleScopeChange = useCallback(
|
||||
async (workspaceIds: string[]) => {
|
||||
if (!viewingGroup || !organizationId) return
|
||||
if (workspaceIds.length === 0) {
|
||||
toast.error("Can't remove the last workspace", {
|
||||
description: 'A group must target at least one workspace. Delete the group instead.',
|
||||
})
|
||||
return
|
||||
}
|
||||
// Zero workspaces is allowed: the group then governs nothing (the resolver
|
||||
// inner-joins on the workspace link table, so an empty group never matches
|
||||
// any workspace). Re-add a workspace to make it active again.
|
||||
const previous = viewingGroup
|
||||
const seq = ++scopeWriteSeqRef.current
|
||||
|
||||
@@ -983,7 +979,6 @@ export function AccessControl() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaces: organizationWorkspaces.filter((ws) => workspaceIds.includes(ws.id)),
|
||||
}
|
||||
: null
|
||||
@@ -992,7 +987,6 @@ export function AccessControl() {
|
||||
const result = await updatePermissionGroup.mutateAsync({
|
||||
id: viewingGroup.id,
|
||||
organizationId,
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds,
|
||||
})
|
||||
|
||||
@@ -1001,7 +995,6 @@ export function AccessControl() {
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
appliesToAllWorkspaces: result.permissionGroup.appliesToAllWorkspaces,
|
||||
workspaces: organizationWorkspaces.filter((ws) =>
|
||||
result.permissionGroup.workspaceIds.includes(ws.id)
|
||||
),
|
||||
@@ -1043,8 +1036,7 @@ export function AccessControl() {
|
||||
? {
|
||||
...prev,
|
||||
isDefault: result.permissionGroup.isDefault,
|
||||
appliesToAllWorkspaces: result.permissionGroup.appliesToAllWorkspaces,
|
||||
workspaces: result.permissionGroup.appliesToAllWorkspaces
|
||||
workspaces: result.permissionGroup.isDefault
|
||||
? []
|
||||
: organizationWorkspaces.filter((ws) =>
|
||||
result.permissionGroup.workspaceIds.includes(ws.id)
|
||||
@@ -1294,7 +1286,7 @@ export function AccessControl() {
|
||||
{viewingGroup.workspaces.length === 0
|
||||
? 'Applies to no one yet — add workspaces below to choose who this group governs.'
|
||||
: members.length === 0
|
||||
? 'Applies to all members of its workspaces, including external members.'
|
||||
? 'Applies to all members of its workspaces.'
|
||||
: `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}.`}
|
||||
</p>
|
||||
)}
|
||||
@@ -1376,6 +1368,7 @@ export function AccessControl() {
|
||||
}}
|
||||
srTitle='Configure Permissions'
|
||||
size='xl'
|
||||
className='h-[84vh]'
|
||||
>
|
||||
<ChipModalHeader
|
||||
onClose={() => {
|
||||
@@ -1405,7 +1398,7 @@ export function AccessControl() {
|
||||
}
|
||||
/>
|
||||
{configTab === 'members' && !viewingGroup.isDefault && (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<span className='text-[var(--text-body)] text-sm'>
|
||||
{members.length === 0
|
||||
@@ -1431,9 +1424,11 @@ export function AccessControl() {
|
||||
))}
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className='py-6 text-center text-[var(--text-muted)] text-sm'>
|
||||
This group applies to everyone in its workspaces, including external members.
|
||||
Add members to restrict it to specific people.
|
||||
<div className='flex flex-1 items-center justify-center px-6 text-center'>
|
||||
<span className='max-w-md text-[var(--text-muted)] text-sm'>
|
||||
This group applies to everyone in its workspaces, including external members.
|
||||
Add members to restrict it to specific people.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='-mx-2 flex flex-col gap-y-0.5'>
|
||||
|
||||
@@ -111,7 +111,6 @@ export interface CreatePermissionGroupData {
|
||||
description?: string
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
isDefault?: boolean
|
||||
appliesToAllWorkspaces?: boolean
|
||||
workspaceIds?: string[]
|
||||
}
|
||||
|
||||
@@ -140,7 +139,6 @@ export interface UpdatePermissionGroupData {
|
||||
description?: string | null
|
||||
config?: Partial<PermissionGroupConfig>
|
||||
isDefault?: boolean
|
||||
appliesToAllWorkspaces?: boolean
|
||||
workspaceIds?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -13,72 +13,34 @@ describe('createPermissionGroupBodySchema', () => {
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-default group set to all workspaces', () => {
|
||||
it('accepts a default group', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Engineering',
|
||||
appliesToAllWorkspaces: true,
|
||||
name: 'Baseline',
|
||||
isDefault: true,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a specific-scope group with at least one workspace', () => {
|
||||
it('accepts a specific-scope group with workspaces', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Contractors',
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a specific-scope group with no workspaces', () => {
|
||||
it('accepts a name + empty workspaces (the create route enforces at least one)', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Contractors',
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a specific-scope group that omits workspaceIds', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Contractors',
|
||||
appliesToAllWorkspaces: false,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a default group that targets specific workspaces', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Baseline',
|
||||
isDefault: true,
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a default group that applies to all workspaces', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Baseline',
|
||||
isDefault: true,
|
||||
appliesToAllWorkspaces: true,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a default group with workspaceIds (appliesToAllWorkspaces omitted)', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Baseline',
|
||||
isDefault: true,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an all-workspaces group that also names specific workspaces', () => {
|
||||
const result = createPermissionGroupBodySchema.safeParse({
|
||||
name: 'Engineering',
|
||||
appliesToAllWorkspaces: true,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
@@ -94,51 +56,27 @@ describe('updatePermissionGroupBodySchema', () => {
|
||||
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: false }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects switching to specific scope with no workspaces', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: [],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
it('accepts emptying scope (group then governs nothing)', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({ workspaceIds: [] })
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts switching to specific scope with workspaces', () => {
|
||||
it('accepts a specific scope with workspaces', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: ['ws-1', 'ws-2'],
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects making a specific-scope group the default', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
isDefault: true,
|
||||
appliesToAllWorkspaces: false,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
it('accepts promoting a group to the default', () => {
|
||||
expect(updatePermissionGroupBodySchema.safeParse({ isDefault: true }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects workspaceIds when making the group the default', () => {
|
||||
it('rejects promoting to the default while naming workspaces', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
isDefault: true,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects workspaceIds on an all-workspaces update', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
appliesToAllWorkspaces: true,
|
||||
workspaceIds: ['ws-1'],
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects setting a non-default group to all workspaces', () => {
|
||||
const result = updatePermissionGroupBodySchema.safeParse({
|
||||
appliesToAllWorkspaces: true,
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,9 +65,10 @@ export const permissionGroupSchema = z.object({
|
||||
creatorEmail: z.string().nullable(),
|
||||
memberCount: z.number(),
|
||||
isDefault: z.boolean(),
|
||||
/** When true the group governs every workspace; when false only `workspaces`. */
|
||||
appliesToAllWorkspaces: z.boolean(),
|
||||
/** Workspaces targeted when `appliesToAllWorkspaces` is false (empty otherwise). */
|
||||
/**
|
||||
* Workspaces this group targets. Empty for the default group (which governs
|
||||
* every workspace) and for a non-default group scoped to nothing.
|
||||
*/
|
||||
workspaces: z.array(permissionGroupWorkspaceRefSchema),
|
||||
})
|
||||
export type PermissionGroup = z.output<typeof permissionGroupSchema>
|
||||
@@ -82,8 +83,7 @@ export const permissionGroupWriteSchema = z.object({
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
isDefault: z.boolean(),
|
||||
appliesToAllWorkspaces: z.boolean(),
|
||||
/** Ids of targeted workspaces when `appliesToAllWorkspaces` is false. */
|
||||
/** Ids of targeted workspaces (empty for the default group). */
|
||||
workspaceIds: z.array(z.string()),
|
||||
})
|
||||
export type PermissionGroupWrite = z.output<typeof permissionGroupWriteSchema>
|
||||
@@ -120,52 +120,29 @@ export const MAX_PERMISSION_GROUP_WORKSPACES = 500
|
||||
const workspaceIdsSchema = z.array(z.string().min(1)).max(MAX_PERMISSION_GROUP_WORKSPACES)
|
||||
|
||||
/**
|
||||
* Enforce the workspace-scope invariants shared by create and update. Only the
|
||||
* organization default group is org-wide; every non-default group targets
|
||||
* specific workspaces:
|
||||
* - all-workspaces scope (`appliesToAllWorkspaces === true`) is allowed only
|
||||
* when `isDefault === true`,
|
||||
* - a specific-scope group (`appliesToAllWorkspaces === false`) must name at
|
||||
* least one workspace and cannot be the default group, and
|
||||
* - an all-workspaces or default group must not name specific workspaces
|
||||
* (otherwise `workspaceIds` would be silently dropped server-side).
|
||||
* The one cross-field scope rule shared by create and update: the organization
|
||||
* default group governs every workspace, so it cannot also name specific
|
||||
* workspaces (they would be silently dropped server-side). "Org-wide" is
|
||||
* definitionally `isDefault` — there is no separate flag — so a default group
|
||||
* with no `workspaceIds` is already the all-workspaces case and needs no
|
||||
* assertion here.
|
||||
*
|
||||
* Everything else is left to the routes: a non-default group targets the
|
||||
* workspaces in `workspaceIds` (empty is allowed on update — the group then
|
||||
* governs nothing, since the resolver inner-joins the workspace link table), and
|
||||
* the create route requires at least one workspace up front.
|
||||
*/
|
||||
function refineWorkspaceScope(
|
||||
body: { appliesToAllWorkspaces?: boolean; workspaceIds?: string[]; isDefault?: boolean },
|
||||
body: { workspaceIds?: string[]; isDefault?: boolean },
|
||||
ctx: z.RefinementCtx
|
||||
) {
|
||||
const allWorkspaces = body.isDefault === true || body.appliesToAllWorkspaces === true
|
||||
if (allWorkspaces && body.workspaceIds && body.workspaceIds.length > 0) {
|
||||
if (body.isDefault === true && body.workspaceIds && body.workspaceIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['workspaceIds'],
|
||||
message: 'workspaceIds can only be set when the group targets specific workspaces',
|
||||
message: 'The default group governs all workspaces and cannot target specific workspaces',
|
||||
})
|
||||
}
|
||||
if (body.appliesToAllWorkspaces === true && body.isDefault !== true) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['appliesToAllWorkspaces'],
|
||||
message:
|
||||
'Only the default group can apply to all workspaces; non-default groups must target specific workspaces',
|
||||
})
|
||||
}
|
||||
if (body.appliesToAllWorkspaces === false) {
|
||||
if (!body.workspaceIds || body.workspaceIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['workspaceIds'],
|
||||
message: 'Select at least one workspace when the group targets specific workspaces',
|
||||
})
|
||||
}
|
||||
if (body.isDefault === true) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['appliesToAllWorkspaces'],
|
||||
message: 'The default group must apply to all workspaces',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const createPermissionGroupBodySchema = z
|
||||
@@ -174,7 +151,6 @@ export const createPermissionGroupBodySchema = z
|
||||
description: z.string().max(500).optional(),
|
||||
config: permissionGroupConfigSchema.optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
appliesToAllWorkspaces: z.boolean().optional(),
|
||||
workspaceIds: workspaceIdsSchema.optional(),
|
||||
})
|
||||
.superRefine(refineWorkspaceScope)
|
||||
@@ -185,7 +161,6 @@ export const updatePermissionGroupBodySchema = z
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
config: permissionGroupConfigSchema.optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
appliesToAllWorkspaces: z.boolean().optional(),
|
||||
workspaceIds: workspaceIdsSchema.optional(),
|
||||
})
|
||||
.superRefine(refineWorkspaceScope)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- migration-safe: `applies_to_all_workspaces` was a derived mirror of `is_default` (create/update routes kept them equal; migration 0246 backfilled legacy rows) that no enforcement/execution path ever read — resolveWorkspaceGroup inner-joins permission_group_workspace and treats org-wide as definitionally `is_default`. All readers/writers are removed in this same change and only admin permission-group management routes touched it, so the sole exposure is a brief deploy-cutover window on those admin routes — accepted (no execution-path impact).
|
||||
ALTER TABLE "permission_group" DROP COLUMN "applies_to_all_workspaces";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1737,6 +1737,13 @@
|
||||
"when": 1782158722013,
|
||||
"tag": "0248_limit_notifications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 249,
|
||||
"version": "7",
|
||||
"when": 1782260194924,
|
||||
"tag": "0249_drop_permission_group_applies_to_all_workspaces",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3064,11 +3064,10 @@ export const credentialSetInvitation = pgTable(
|
||||
* an organization.
|
||||
*
|
||||
* Scope invariant: the organization's single default group (`isDefault`) is
|
||||
* org-wide (`appliesToAllWorkspaces = true`) and governs everyone not covered by
|
||||
* another group. Every non-default group
|
||||
* targets specific workspaces (`appliesToAllWorkspaces = false` with rows in
|
||||
* `permission_group_workspace`) — the all-workspaces scope is reserved for the
|
||||
* default group. Enforced by the API contracts/routes, not a DB constraint.
|
||||
* org-wide and governs everyone not covered by another group. Every non-default
|
||||
* group targets specific workspaces (rows in `permission_group_workspace`), and a
|
||||
* non-default group with no rows governs nothing. Being org-wide is definitionally
|
||||
* `isDefault` — there is no separate flag. Enforced by the API contracts/routes.
|
||||
*
|
||||
* Member invariant: a non-default group with no `permission_group_member` rows
|
||||
* governs every member of its workspaces (including external members); adding
|
||||
@@ -3090,7 +3089,6 @@ export const permissionGroup = pgTable(
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
appliesToAllWorkspaces: boolean('applies_to_all_workspaces').notNull().default(true),
|
||||
},
|
||||
(table) => ({
|
||||
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
|
||||
@@ -3105,9 +3103,9 @@ export const permissionGroup = pgTable(
|
||||
)
|
||||
|
||||
/**
|
||||
* Workspaces a `permission_group` targets when `applies_to_all_workspaces` is
|
||||
* false. Rows are absent for organization-wide groups. A group with zero rows
|
||||
* while `applies_to_all_workspaces = false` governs no workspace.
|
||||
* Workspaces a non-default `permission_group` targets. Rows are absent for the
|
||||
* organization-wide default group; a non-default group with zero rows governs no
|
||||
* workspace.
|
||||
*/
|
||||
export const permissionGroupWorkspace = pgTable(
|
||||
'permission_group_workspace',
|
||||
|
||||
@@ -1027,7 +1027,6 @@ export const schemaMock = {
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt',
|
||||
isDefault: 'isDefault',
|
||||
appliesToAllWorkspaces: 'appliesToAllWorkspaces',
|
||||
},
|
||||
permissionGroupWorkspace: {
|
||||
id: 'id',
|
||||
|
||||
Reference in New Issue
Block a user