Skip to content

Commit bd11949

Browse files
committed
fix(web): guard team invites without instance scope
1 parent 8f4c5e9 commit bd11949

4 files changed

Lines changed: 34 additions & 6 deletions

File tree

apps/web/app/(dashboard)/team/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default async function TeamPage() {
1515
<TeamClient
1616
currentUserId={session.user.id}
1717
currentUserRole={session.user.role}
18+
hasInstanceScope={Boolean(session.user.instanceId)}
1819
initialMembers={members}
1920
initialPendingInvites={pendingInvites}
2021
/>

apps/web/app/(dashboard)/team/team-client.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ type InviteValues = z.infer<typeof inviteSchema>
5151
interface TeamClientProps {
5252
currentUserId: string
5353
currentUserRole: string
54+
hasInstanceScope: boolean
5455
initialMembers: User[]
5556
initialPendingInvites: Invitation[]
5657
}
@@ -65,6 +66,7 @@ function getDisplayedRoles(entity: Pick<User | Invitation, 'role' | 'roles'>): s
6566
export function TeamClient({
6667
currentUserId,
6768
currentUserRole,
69+
hasInstanceScope,
6870
initialMembers,
6971
initialPendingInvites,
7072
}: TeamClientProps) {
@@ -200,7 +202,7 @@ export function TeamClient({
200202
Manage members and pending invitations
201203
</p>
202204
</div>
203-
{canManage(currentUserRole) && (
205+
{canManage(currentUserRole) && hasInstanceScope && (
204206
<Button onClick={() => setIsInviteOpen(true)} size="sm" data-testid="team-invite-open">
205207
<UserPlus className="size-4 mr-2" />
206208
Invite member

apps/web/lib/actions/dashboard-standalone.test.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ const sessionSource = readFileSync(
2121
path.join(repoRoot, 'lib/auth/session.ts'),
2222
'utf8',
2323
)
24+
const usersActionSource = readFileSync(
25+
path.join(repoRoot, 'lib/actions/users.ts'),
26+
'utf8',
27+
)
28+
const teamPageSource = readFileSync(
29+
path.join(repoRoot, 'app/(dashboard)/team/page.tsx'),
30+
'utf8',
31+
)
32+
const teamClientSource = readFileSync(
33+
path.join(repoRoot, 'app/(dashboard)/team/team-client.tsx'),
34+
'utf8',
35+
)
2436

2537
const sidebarLinkedPages = [
2638
'app/(dashboard)/alerts/page.tsx',
@@ -126,6 +138,17 @@ test('fresh standalone sessions promote the first active user to instance admin'
126138
assert.match(sessionSource, /set\(\{ role: INSTANCE_ADMIN_ROLE, roles, updatedAt: new Date\(\) \}\)/)
127139
})
128140

141+
test('team invitations are disabled without an instance-backed scope', () => {
142+
assert.match(usersActionSource, /const currentScope = resolveOptionalActionScope\(session\)/)
143+
assert.match(
144+
usersActionSource,
145+
/if \(!currentScope\) return \{ error: 'Team invitations require an instance to be configured' \}/,
146+
)
147+
assert.match(teamPageSource, /hasInstanceScope=\{Boolean\(session\.user\.instanceId\)\}/)
148+
assert.match(teamClientSource, /hasInstanceScope: boolean/)
149+
assert.match(teamClientSource, /canManage\(currentUserRole\) && hasInstanceScope/)
150+
})
151+
129152
test('administration pages use normalized role checks', () => {
130153
for (const relativePath of administrationPages) {
131154
const source = readFileSync(path.join(repoRoot, relativePath), 'utf8')

apps/web/lib/actions/users.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ export async function getOrgUsers(): Promise<{ members: User[]; pendingInvites:
5757
export async function inviteUser(
5858
input: { email: string; roles: string[] },
5959
): Promise<{ inviteLink: string } | { restored: true } | { error: string }> {
60-
const currentScope = resolveCurrentActionScope(await getRequiredSession())
60+
const session = await getRequiredSession()
61+
const currentScope = resolveOptionalActionScope(session)
62+
if (!currentScope) return { error: 'Team invitations require an instance to be configured' }
6163
const instanceId = currentScope
6264
await requireInstanceAccess(instanceId)
6365
const parsed = inviteSchema.safeParse(input)
@@ -69,7 +71,7 @@ export async function inviteUser(
6971
const nextRole = getPrimaryRole(nextRoles)
7072

7173
try {
72-
const session = await requireInstanceAdminAccess(instanceId)
74+
const adminSession = await requireInstanceAdminAccess(instanceId)
7375

7476
// Check for a previously removed user — restore them rather than re-registering,
7577
// since their account (and email) still exist in the database.
@@ -90,7 +92,7 @@ export async function inviteUser(
9092

9193
await writeAuditEvent(tx, {
9294
instanceId: instanceId,
93-
actorUserId: session.user.id,
95+
actorUserId: adminSession.user.id,
9496
action: 'user.restored',
9597
targetType: 'user',
9698
targetId: removedUser.id,
@@ -144,7 +146,7 @@ export async function inviteUser(
144146
role: nextRole,
145147
roles: nextRoles,
146148
instanceId: instanceId,
147-
invitedById: session.user.id,
149+
invitedById: adminSession.user.id,
148150
expiresAt,
149151
})
150152
.returning()
@@ -153,7 +155,7 @@ export async function inviteUser(
153155

154156
await writeAuditEvent(tx, {
155157
instanceId: instanceId,
156-
actorUserId: session.user.id,
158+
actorUserId: adminSession.user.id,
157159
action: 'invitation.created',
158160
targetType: 'invitation',
159161
targetId: createdInvite.id,

0 commit comments

Comments
 (0)