Skip to content

Commit 4b71c14

Browse files
author
Deathgiver
committed
feat(permissions): rename contacts permission to contacts / inbox and gate all sidebar menus
The contacts permission now controls both the Contacts and Inbox sections (label renamed to "Contacts / Inbox" across all 20 locales, jsonb key unchanged). Every sidebar item is now permission-gated: Inbox uses the shared contacts-access rule, AI Agents and Tools follow flows, and Keywords, Triggers and Webhooks are super-admin only. Matching server-side guards added so hidden menus cannot be reached by URL: /inbox requires contacts access, the (ai) layout and /tools require flows, and both automated-responses twins, triggers, webhooks plus their @Folders slots require superAdmin. The workspace landing route is now derived from PERMISSION_NAV, prefers inbox for contacts members, and fails closed (404) when a member has no accessible section.
1 parent e8bf25b commit 4b71c14

45 files changed

Lines changed: 473 additions & 105 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/builder/__tests__/contacts-route-guards.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ vi.mock("next/navigation", () => ({
1919
notFound: mockNotFound,
2020
}))
2121

22+
vi.mock("@/lib/workspace/require-not-scheduled-for-deletion", () => ({
23+
enforceWorkspaceNotScheduledForDeletionFromRequest: vi.fn(
24+
async () => undefined,
25+
),
26+
}))
27+
2228
vi.mock("next-intl/server", () => ({
2329
getTranslations: vi.fn(async () => (key: string) => key),
2430
}))
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
const { mockGetCurrentUserAndTargetWorkspace, mockNotFound } = vi.hoisted(
6+
() => ({
7+
mockGetCurrentUserAndTargetWorkspace: vi.fn(),
8+
mockNotFound: vi.fn(() => {
9+
throw new Error("not found")
10+
}),
11+
}),
12+
)
13+
14+
vi.mock("@/lib/auth/utils", () => ({
15+
getCurrentUserAndTargetWorkspace: mockGetCurrentUserAndTargetWorkspace,
16+
}))
17+
18+
vi.mock("next/navigation", () => ({
19+
notFound: mockNotFound,
20+
}))
21+
22+
vi.mock("@/lib/workspace/require-not-scheduled-for-deletion", () => ({
23+
enforceWorkspaceNotScheduledForDeletionFromRequest: vi.fn(
24+
async () => undefined,
25+
),
26+
}))
27+
28+
vi.mock("next/headers", () => ({
29+
cookies: vi.fn(async () => ({
30+
get: () => undefined,
31+
})),
32+
}))
33+
34+
vi.mock("@/features/chat/chat-layout", () => ({
35+
ChatLayout: () => null,
36+
}))
37+
38+
vi.mock("@/features/chat/store/chat-store-provider", () => ({
39+
ChatStoreProvider: ({ children }: { children: unknown }) => children,
40+
}))
41+
42+
vi.mock("@/features/custom-fields/provider/custom-field-store-context", () => ({
43+
CustomFieldStoreProvider: ({ children }: { children: unknown }) => children,
44+
}))
45+
46+
vi.mock("@/features/flows/provider/flow-store-context", () => ({
47+
FlowStoreProvider: ({ children }: { children: unknown }) => children,
48+
}))
49+
50+
vi.mock("@/features/inboxes/provider/inbox-store-context", () => ({
51+
InboxStoreProvider: ({ children }: { children: unknown }) => children,
52+
}))
53+
54+
vi.mock("@/features/saved-replies/provider/saved-reply-store-context", () => ({
55+
SavedReplyStoreProvider: ({ children }: { children: unknown }) => children,
56+
}))
57+
58+
vi.mock("@/features/sequences/provider/sequence-store-context", () => ({
59+
SequenceStoreProvider: ({ children }: { children: unknown }) => children,
60+
}))
61+
62+
vi.mock("@/features/tags/provider/tag-store-context", () => ({
63+
TagStoreProvider: ({ children }: { children: unknown }) => children,
64+
}))
65+
66+
vi.mock("@/features/users/provider/user-store-context", () => ({
67+
UserStoreProvider: ({ children }: { children: unknown }) => children,
68+
}))
69+
70+
const { default: InboxPage } = await import(
71+
"../src/app/space/[workspaceId]/inbox/page"
72+
)
73+
74+
const basePermissions = {
75+
superAdmin: false,
76+
analytics: false,
77+
flows: false,
78+
contacts: false,
79+
onlyAssignedContacts: false,
80+
emailAndPhone: false,
81+
broadcast: false,
82+
ecommerce: false,
83+
}
84+
85+
describe("inbox route guards", () => {
86+
beforeEach(() => {
87+
vi.clearAllMocks()
88+
})
89+
90+
test("allows assigned-only members to reach the inbox page", async () => {
91+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
92+
user: { id: "user-1" },
93+
targetWorkspaceMember: {
94+
permissions: {
95+
...basePermissions,
96+
onlyAssignedContacts: true,
97+
},
98+
},
99+
})
100+
101+
await expect(
102+
InboxPage({ params: Promise.resolve({ workspaceId: "ws-1" }) }),
103+
).resolves.toBeDefined()
104+
105+
expect(mockNotFound).not.toHaveBeenCalled()
106+
})
107+
108+
test("rejects members without full or assigned-only contact access", async () => {
109+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
110+
user: { id: "user-1" },
111+
targetWorkspaceMember: {
112+
permissions: basePermissions,
113+
},
114+
})
115+
116+
await expect(
117+
InboxPage({ params: Promise.resolve({ workspaceId: "ws-1" }) }),
118+
).rejects.toThrow("not found")
119+
120+
expect(mockNotFound).toHaveBeenCalled()
121+
})
122+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// @vitest-environment node
2+
3+
import { beforeEach, describe, expect, test, vi } from "vitest"
4+
5+
const { mockGetCurrentUserAndTargetWorkspace, mockNotFound } = vi.hoisted(
6+
() => ({
7+
mockGetCurrentUserAndTargetWorkspace: vi.fn(),
8+
mockNotFound: vi.fn(() => {
9+
throw new Error("not found")
10+
}),
11+
}),
12+
)
13+
14+
vi.mock("@/lib/auth/utils", () => ({
15+
getCurrentUserAndTargetWorkspace: mockGetCurrentUserAndTargetWorkspace,
16+
}))
17+
18+
vi.mock("next/navigation", () => ({
19+
notFound: mockNotFound,
20+
}))
21+
22+
vi.mock("@/lib/workspace/require-not-scheduled-for-deletion", () => ({
23+
enforceWorkspaceNotScheduledForDeletionFromRequest: vi.fn(
24+
async () => undefined,
25+
),
26+
}))
27+
28+
vi.mock("@/features/automated-response/keywords-tab", () => ({
29+
KeywordsTab: () => null,
30+
}))
31+
32+
vi.mock("@/features/automated-response/keywords-description", () => ({
33+
KeywordsDescription: () => null,
34+
}))
35+
36+
vi.mock("@/features/flows/provider/flow-store-context", () => ({
37+
FlowStoreProvider: ({ children }: { children: unknown }) => children,
38+
}))
39+
40+
vi.mock("@/features/folders/provider/folder-store-context", () => ({
41+
FolderStoreProvider: ({ children }: { children: unknown }) => children,
42+
}))
43+
44+
const { default: PageAutomatedResponsesFolderableLayout } = await import(
45+
"../src/app/space/[workspaceId]/(has-folder)/page-automated-responses/layout"
46+
)
47+
const { default: PageAutomatedResponsesLayout } = await import(
48+
"../src/app/space/[workspaceId]/page-automated-responses/layout"
49+
)
50+
51+
const basePermissions = {
52+
superAdmin: false,
53+
analytics: false,
54+
flows: false,
55+
contacts: false,
56+
onlyAssignedContacts: false,
57+
emailAndPhone: false,
58+
broadcast: false,
59+
ecommerce: false,
60+
}
61+
62+
describe("page-automated-responses route guards", () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
})
66+
67+
test("allows super admins to reach the outbound keywords layouts", async () => {
68+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
69+
targetWorkspaceMember: {
70+
permissions: {
71+
...basePermissions,
72+
superAdmin: true,
73+
},
74+
},
75+
})
76+
77+
await expect(
78+
PageAutomatedResponsesFolderableLayout({
79+
children: null,
80+
folders: null,
81+
params: Promise.resolve({ workspaceId: "ws-1" }),
82+
}),
83+
).resolves.toBeDefined()
84+
await expect(
85+
PageAutomatedResponsesLayout({
86+
children: null,
87+
params: Promise.resolve({ workspaceId: "ws-1" }),
88+
}),
89+
).resolves.toBeDefined()
90+
91+
expect(mockNotFound).not.toHaveBeenCalled()
92+
})
93+
94+
test("rejects fully-permissioned non-super-admins on the outbound keywords layouts", async () => {
95+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
96+
targetWorkspaceMember: {
97+
permissions: {
98+
...basePermissions,
99+
analytics: true,
100+
flows: true,
101+
contacts: true,
102+
onlyAssignedContacts: true,
103+
emailAndPhone: true,
104+
broadcast: true,
105+
ecommerce: true,
106+
},
107+
},
108+
})
109+
110+
await expect(
111+
PageAutomatedResponsesFolderableLayout({
112+
children: null,
113+
folders: null,
114+
params: Promise.resolve({ workspaceId: "ws-1" }),
115+
}),
116+
).rejects.toThrow("not found")
117+
await expect(
118+
PageAutomatedResponsesLayout({
119+
children: null,
120+
params: Promise.resolve({ workspaceId: "ws-1" }),
121+
}),
122+
).rejects.toThrow("not found")
123+
124+
expect(mockNotFound).toHaveBeenCalled()
125+
})
126+
})

apps/builder/__tests__/require-workspace-permission.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,46 @@ describe("resolveGuardedWorkspaceId", () => {
110110
),
111111
).rejects.toThrow("not found")
112112
})
113+
114+
test("returns the workspace id for super admins on superAdmin-gated routes", async () => {
115+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
116+
targetWorkspaceMember: {
117+
permissions: {
118+
...basePermissions,
119+
superAdmin: true,
120+
},
121+
},
122+
})
123+
124+
await expect(
125+
resolveGuardedWorkspaceId(
126+
Promise.resolve({ workspaceId: "ws-1" }),
127+
"superAdmin",
128+
),
129+
).resolves.toBe("ws-1")
130+
})
131+
132+
test("rejects fully-permissioned non-super-admins on superAdmin-gated routes", async () => {
133+
mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({
134+
targetWorkspaceMember: {
135+
permissions: {
136+
...basePermissions,
137+
analytics: true,
138+
flows: true,
139+
contacts: true,
140+
onlyAssignedContacts: true,
141+
emailAndPhone: true,
142+
broadcast: true,
143+
ecommerce: true,
144+
},
145+
},
146+
})
147+
148+
await expect(
149+
resolveGuardedWorkspaceId(
150+
Promise.resolve({ workspaceId: "ws-1" }),
151+
"superAdmin",
152+
),
153+
).rejects.toThrow("not found")
154+
})
113155
})

apps/builder/__tests__/workspace-permission-routes.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, expect, test } from "vitest"
44
import {
5+
hasContactsAccess,
56
hasWorkspacePermission,
67
PERMISSION_NAV,
78
resolveWorkspaceLandingSegment,
@@ -38,6 +39,24 @@ describe("workspace permission routes", () => {
3839
})
3940
})
4041

42+
describe("hasContactsAccess", () => {
43+
test("grants access with the full contacts flag", () => {
44+
expect(hasContactsAccess({ contacts: true })).toBe(true)
45+
})
46+
47+
test("grants access with only the assigned-contacts flag", () => {
48+
expect(hasContactsAccess({ onlyAssignedContacts: true })).toBe(true)
49+
})
50+
51+
test("grants access to super admins", () => {
52+
expect(hasContactsAccess({ superAdmin: true })).toBe(true)
53+
})
54+
55+
test("denies access without any contacts flag", () => {
56+
expect(hasContactsAccess({})).toBe(false)
57+
})
58+
})
59+
4160
describe("resolveWorkspaceLandingSegment", () => {
4261
test("lands super admins on the dashboard", () => {
4362
expect(resolveWorkspaceLandingSegment({ superAdmin: true })).toBe(
@@ -61,18 +80,35 @@ describe("resolveWorkspaceLandingSegment", () => {
6180
).toBe("flows")
6281
})
6382

64-
test("lands on the first granted section in nav priority order", () => {
83+
test("lands contacts members on the inbox in nav priority order", () => {
6584
expect(
6685
resolveWorkspaceLandingSegment({
6786
superAdmin: false,
6887
analytics: false,
6988
flows: false,
7089
contacts: true,
7190
}),
72-
).toBe("contacts")
91+
).toBe("inbox")
92+
})
93+
94+
test("lands assigned-only members on the inbox", () => {
95+
expect(resolveWorkspaceLandingSegment({ onlyAssignedContacts: true })).toBe(
96+
"inbox",
97+
)
98+
})
99+
100+
test("prefers the inbox over flows for contacts members", () => {
101+
expect(
102+
resolveWorkspaceLandingSegment({ flows: true, contacts: true }),
103+
).toBe("inbox")
104+
})
105+
106+
test("lands ecommerce-only members on products", () => {
107+
expect(resolveWorkspaceLandingSegment({ ecommerce: true })).toBe("products")
73108
})
74109

75-
test("falls back to the ungated inbox when no section is granted", () => {
76-
expect(resolveWorkspaceLandingSegment({})).toBe("inbox")
110+
test("returns null when no section is granted", () => {
111+
expect(resolveWorkspaceLandingSegment({})).toBeNull()
112+
expect(resolveWorkspaceLandingSegment({ emailAndPhone: true })).toBeNull()
77113
})
78114
})

apps/builder/messages/ar.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1649,7 +1649,7 @@
16491649
"superAdmin": "مسؤول أعلى",
16501650
"analytics": "التحليلات",
16511651
"flows": "المسارات",
1652-
"contacts": "جهات الاتصال",
1652+
"contacts": "جهات الاتصال / صندوق الوارد",
16531653
"onlyAssignedContacts": "جهات الاتصال المسندة فقط",
16541654
"emailAndPhone": "البريد الإلكتروني والهاتف",
16551655
"broadcast": "البث",

0 commit comments

Comments
 (0)