Skip to content

Commit 8a9b1e3

Browse files
authored
feat: Customizable dashboard widgets with permission-based access control (#66)
* feat: Add customizable dashboard widgets Allow users to create their own analytics widgets on the dashboard instead of predefined metrics. This enables different users/organizations to track what matters most to them. - Add DashboardWidget model with flexible filter configuration (JSONB) - Add CRUD API handlers for widgets with owner-only edit/delete - Add widget data query endpoint supporting multiple data sources - Support for messages, contacts, campaigns, transfers, sessions data - Update DashboardView with widget builder dialog - Add shared widget support for team visibility - Include percentage change comparison with previous period * fix: Restore default stat widgets and fix data source dropdown - Add back the 4 default stat cards (Total Messages, Active Contacts, Chatbot Sessions, Total Campaigns) that are always shown - Fix data source dropdown not showing options by handling the API response envelope correctly - Custom widgets now appear in a separate section below defaults - Both default stats and custom widgets update with date range changes * feat: Add permission-based access control for dashboard widgets - Add analytics:write and analytics:delete permissions - Dashboard widget CRUD requires appropriate analytics permissions - Only widget owner can edit/delete their widgets - Super admin can edit system role permissions via UI - Fix migration to seed missing permissions incrementally - Use super admin (admin@admin.com) for default widget ownership - Add comprehensive tests for widget permission checks * fix: Hide widget buttons based on user permissions - Add permission checks to DashboardView - Hide "Add Widget" button if user lacks analytics:write - Hide edit button if user lacks analytics:write - Hide delete button if user lacks analytics:delete * test: Add e2e tests for dashboard widget permissions - Test admin user can see Add Widget, edit, delete buttons - Test user with analytics:write can see Add Widget and edit - Test user with analytics:delete can see delete button - Test user with only analytics:read cannot see any action buttons - Test user with full analytics permissions sees all controls * fix: Update dashboard e2e tests for new widget names - Update dashboard description text assertion - Fix stat card names to match new widget names - Use getByRole for Recent Messages heading to avoid ambiguity * fix: Filter contacts widget by last_message_at for active contacts Active Contacts widget should show contacts with recent message activity, not contacts created within the date range. * chore: Remove debug console.log statements from DashboardView Clean up debug logging that was added during development.
1 parent 58a5d46 commit 8a9b1e3

File tree

13 files changed

+2769
-174
lines changed

13 files changed

+2769
-174
lines changed

cmd/whatomate/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,17 @@ func setupRoutes(g *fastglue.Fastglue, app *handlers.App, lo logf.Logger, basePa
604604
g.GET("/api/analytics/agents/{id}", app.GetAgentDetails)
605605
g.GET("/api/analytics/agents/comparison", app.GetAgentComparison)
606606

607+
// Dashboard Widgets (customizable analytics)
608+
g.GET("/api/dashboard/widgets", app.ListDashboardWidgets)
609+
g.POST("/api/dashboard/widgets", app.CreateDashboardWidget)
610+
g.GET("/api/dashboard/widgets/data-sources", app.GetWidgetDataSources)
611+
g.GET("/api/dashboard/widgets/data", app.GetAllWidgetsData)
612+
g.GET("/api/dashboard/widgets/{id}", app.GetDashboardWidget)
613+
g.PUT("/api/dashboard/widgets/{id}", app.UpdateDashboardWidget)
614+
g.DELETE("/api/dashboard/widgets/{id}", app.DeleteDashboardWidget)
615+
g.GET("/api/dashboard/widgets/{id}/data", app.GetWidgetData)
616+
g.POST("/api/dashboard/widgets/reorder", app.ReorderDashboardWidgets)
617+
607618
// Organization Settings
608619
g.GET("/api/org/settings", app.GetOrganizationSettings)
609620
g.PUT("/api/org/settings", app.UpdateOrganizationSettings)
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { test, expect, Page } from '@playwright/test'
2+
import { loginAsAdmin, ApiHelper, generateUniqueName, generateUniqueEmail } from '../../helpers'
3+
4+
// Helper to login with specific credentials
5+
async function loginWithCredentials(page: Page, email: string, password: string) {
6+
await page.goto('/login')
7+
await page.locator('input[name="email"], input[type="email"]').fill(email)
8+
await page.locator('input[name="password"], input[type="password"]').fill(password)
9+
await page.locator('button[type="submit"]').click()
10+
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 10000 })
11+
}
12+
13+
test.describe('Dashboard Widget Permissions', () => {
14+
test.describe('Admin User (with full permissions)', () => {
15+
test.beforeEach(async ({ page }) => {
16+
await loginAsAdmin(page)
17+
await page.goto('/')
18+
await page.waitForLoadState('networkidle')
19+
})
20+
21+
test('admin can see Add Widget button', async ({ page }) => {
22+
const addButton = page.locator('button').filter({ hasText: /Add Widget/i })
23+
await expect(addButton).toBeVisible({ timeout: 10000 })
24+
})
25+
26+
test('admin can see edit and delete buttons on widget hover', async ({ page }) => {
27+
// Wait for widgets to load
28+
await page.waitForSelector('.card-depth', { timeout: 10000 })
29+
30+
// Hover over first widget
31+
const firstWidget = page.locator('.card-depth').first()
32+
await firstWidget.hover()
33+
34+
// Should see edit button (pencil icon)
35+
const editButton = firstWidget.locator('button[title="Edit widget"]')
36+
await expect(editButton).toBeVisible()
37+
38+
// Should see delete button (trash icon)
39+
const deleteButton = firstWidget.locator('button[title="Delete widget"]')
40+
await expect(deleteButton).toBeVisible()
41+
})
42+
})
43+
44+
test.describe('User with Analytics Write Permission', () => {
45+
const roleName = generateUniqueName('E2E Analytics Write')
46+
const userEmail = generateUniqueEmail('e2e-analytics-write')
47+
const userPassword = 'Password123!'
48+
49+
let api: ApiHelper
50+
let roleId: string
51+
let userId: string
52+
53+
test.beforeAll(async ({ request }) => {
54+
api = new ApiHelper(request)
55+
await api.loginAsAdmin()
56+
57+
// Create role with analytics read and write permissions
58+
const permissions = await api.findPermissionKeys([
59+
{ resource: 'analytics', action: 'read' },
60+
{ resource: 'analytics', action: 'write' },
61+
])
62+
63+
const role = await api.createRole({
64+
name: roleName,
65+
description: 'E2E test role with analytics write permission',
66+
permissions,
67+
})
68+
roleId = role.id
69+
70+
// Create user with the custom role
71+
const user = await api.createUser({
72+
email: userEmail,
73+
password: userPassword,
74+
full_name: 'E2E Analytics Write User',
75+
role_id: roleId,
76+
})
77+
userId = user.id
78+
})
79+
80+
test.afterAll(async () => {
81+
if (userId) await api.deleteUser(userId).catch(() => {})
82+
if (roleId) await api.deleteRole(roleId).catch(() => {})
83+
})
84+
85+
test('user with analytics:write can see Add Widget button', async ({ page }) => {
86+
await loginWithCredentials(page, userEmail, userPassword)
87+
await page.goto('/')
88+
await page.waitForLoadState('networkidle')
89+
90+
const addButton = page.locator('button').filter({ hasText: /Add Widget/i })
91+
await expect(addButton).toBeVisible({ timeout: 10000 })
92+
})
93+
94+
test('user with analytics:write can see edit button on widget hover', async ({ page }) => {
95+
await loginWithCredentials(page, userEmail, userPassword)
96+
await page.goto('/')
97+
await page.waitForLoadState('networkidle')
98+
99+
// Wait for widgets to load
100+
await page.waitForSelector('.card-depth', { timeout: 10000 })
101+
102+
// Hover over first widget
103+
const firstWidget = page.locator('.card-depth').first()
104+
await firstWidget.hover()
105+
106+
// Should see edit button
107+
const editButton = firstWidget.locator('button[title="Edit widget"]')
108+
await expect(editButton).toBeVisible()
109+
})
110+
})
111+
112+
test.describe('User with Analytics Delete Permission', () => {
113+
const roleName = generateUniqueName('E2E Analytics Delete')
114+
const userEmail = generateUniqueEmail('e2e-analytics-delete')
115+
const userPassword = 'Password123!'
116+
117+
let api: ApiHelper
118+
let roleId: string
119+
let userId: string
120+
121+
test.beforeAll(async ({ request }) => {
122+
api = new ApiHelper(request)
123+
await api.loginAsAdmin()
124+
125+
// Create role with analytics read and delete permissions (but NOT write)
126+
const permissions = await api.findPermissionKeys([
127+
{ resource: 'analytics', action: 'read' },
128+
{ resource: 'analytics', action: 'delete' },
129+
])
130+
131+
const role = await api.createRole({
132+
name: roleName,
133+
description: 'E2E test role with analytics delete permission',
134+
permissions,
135+
})
136+
roleId = role.id
137+
138+
// Create user with the custom role
139+
const user = await api.createUser({
140+
email: userEmail,
141+
password: userPassword,
142+
full_name: 'E2E Analytics Delete User',
143+
role_id: roleId,
144+
})
145+
userId = user.id
146+
})
147+
148+
test.afterAll(async () => {
149+
if (userId) await api.deleteUser(userId).catch(() => {})
150+
if (roleId) await api.deleteRole(roleId).catch(() => {})
151+
})
152+
153+
test('user with analytics:delete can see delete button on widget hover', async ({ page }) => {
154+
await loginWithCredentials(page, userEmail, userPassword)
155+
await page.goto('/')
156+
await page.waitForLoadState('networkidle')
157+
158+
// Wait for widgets to load
159+
await page.waitForSelector('.card-depth', { timeout: 10000 })
160+
161+
// Hover over first widget
162+
const firstWidget = page.locator('.card-depth').first()
163+
await firstWidget.hover()
164+
165+
// Should see delete button
166+
const deleteButton = firstWidget.locator('button[title="Delete widget"]')
167+
await expect(deleteButton).toBeVisible()
168+
})
169+
170+
test('user with analytics:delete but NOT analytics:write cannot see Add Widget button', async ({ page }) => {
171+
await loginWithCredentials(page, userEmail, userPassword)
172+
await page.goto('/')
173+
await page.waitForLoadState('networkidle')
174+
175+
const addButton = page.locator('button').filter({ hasText: /Add Widget/i })
176+
await expect(addButton).not.toBeVisible()
177+
})
178+
179+
test('user with analytics:delete but NOT analytics:write cannot see edit button', async ({ page }) => {
180+
await loginWithCredentials(page, userEmail, userPassword)
181+
await page.goto('/')
182+
await page.waitForLoadState('networkidle')
183+
184+
// Wait for widgets to load
185+
await page.waitForSelector('.card-depth', { timeout: 10000 })
186+
187+
// Hover over first widget
188+
const firstWidget = page.locator('.card-depth').first()
189+
await firstWidget.hover()
190+
191+
// Should NOT see edit button
192+
const editButton = firstWidget.locator('button[title="Edit widget"]')
193+
await expect(editButton).not.toBeVisible()
194+
})
195+
})
196+
197+
test.describe('User with Analytics Read Only', () => {
198+
const roleName = generateUniqueName('E2E Analytics Read Only')
199+
const userEmail = generateUniqueEmail('e2e-analytics-readonly')
200+
const userPassword = 'Password123!'
201+
202+
let api: ApiHelper
203+
let roleId: string
204+
let userId: string
205+
206+
test.beforeAll(async ({ request }) => {
207+
api = new ApiHelper(request)
208+
await api.loginAsAdmin()
209+
210+
// Create role with only analytics read permission
211+
const permissions = await api.findPermissionKeys([
212+
{ resource: 'analytics', action: 'read' },
213+
])
214+
215+
const role = await api.createRole({
216+
name: roleName,
217+
description: 'E2E test role with analytics read-only permission',
218+
permissions,
219+
})
220+
roleId = role.id
221+
222+
// Create user with the custom role
223+
const user = await api.createUser({
224+
email: userEmail,
225+
password: userPassword,
226+
full_name: 'E2E Analytics Read Only User',
227+
role_id: roleId,
228+
})
229+
userId = user.id
230+
})
231+
232+
test.afterAll(async () => {
233+
if (userId) await api.deleteUser(userId).catch(() => {})
234+
if (roleId) await api.deleteRole(roleId).catch(() => {})
235+
})
236+
237+
test('user with only analytics:read cannot see Add Widget button', async ({ page }) => {
238+
await loginWithCredentials(page, userEmail, userPassword)
239+
await page.goto('/')
240+
await page.waitForLoadState('networkidle')
241+
242+
const addButton = page.locator('button').filter({ hasText: /Add Widget/i })
243+
await expect(addButton).not.toBeVisible()
244+
})
245+
246+
test('user with only analytics:read cannot see edit button on widget hover', async ({ page }) => {
247+
await loginWithCredentials(page, userEmail, userPassword)
248+
await page.goto('/')
249+
await page.waitForLoadState('networkidle')
250+
251+
// Wait for widgets to load
252+
await page.waitForSelector('.card-depth', { timeout: 10000 })
253+
254+
// Hover over first widget
255+
const firstWidget = page.locator('.card-depth').first()
256+
await firstWidget.hover()
257+
258+
// Should NOT see edit button
259+
const editButton = firstWidget.locator('button[title="Edit widget"]')
260+
await expect(editButton).not.toBeVisible()
261+
})
262+
263+
test('user with only analytics:read cannot see delete button on widget hover', async ({ page }) => {
264+
await loginWithCredentials(page, userEmail, userPassword)
265+
await page.goto('/')
266+
await page.waitForLoadState('networkidle')
267+
268+
// Wait for widgets to load
269+
await page.waitForSelector('.card-depth', { timeout: 10000 })
270+
271+
// Hover over first widget
272+
const firstWidget = page.locator('.card-depth').first()
273+
await firstWidget.hover()
274+
275+
// Should NOT see delete button
276+
const deleteButton = firstWidget.locator('button[title="Delete widget"]')
277+
await expect(deleteButton).not.toBeVisible()
278+
})
279+
280+
test('user with only analytics:read can still view dashboard and widgets', async ({ page }) => {
281+
await loginWithCredentials(page, userEmail, userPassword)
282+
await page.goto('/')
283+
await page.waitForLoadState('networkidle')
284+
285+
// Should see dashboard
286+
await expect(page.locator('h1')).toContainText('Dashboard')
287+
288+
// Should see widgets
289+
await page.waitForSelector('.card-depth', { timeout: 10000 })
290+
const widgets = page.locator('.card-depth')
291+
expect(await widgets.count()).toBeGreaterThan(0)
292+
})
293+
})
294+
295+
test.describe('User with Full Analytics Permissions', () => {
296+
const roleName = generateUniqueName('E2E Analytics Full')
297+
const userEmail = generateUniqueEmail('e2e-analytics-full')
298+
const userPassword = 'Password123!'
299+
300+
let api: ApiHelper
301+
let roleId: string
302+
let userId: string
303+
304+
test.beforeAll(async ({ request }) => {
305+
api = new ApiHelper(request)
306+
await api.loginAsAdmin()
307+
308+
// Create role with all analytics permissions
309+
const permissions = await api.findPermissionKeys([
310+
{ resource: 'analytics', action: 'read' },
311+
{ resource: 'analytics', action: 'write' },
312+
{ resource: 'analytics', action: 'delete' },
313+
])
314+
315+
const role = await api.createRole({
316+
name: roleName,
317+
description: 'E2E test role with full analytics permissions',
318+
permissions,
319+
})
320+
roleId = role.id
321+
322+
// Create user with the custom role
323+
const user = await api.createUser({
324+
email: userEmail,
325+
password: userPassword,
326+
full_name: 'E2E Analytics Full User',
327+
role_id: roleId,
328+
})
329+
userId = user.id
330+
})
331+
332+
test.afterAll(async () => {
333+
if (userId) await api.deleteUser(userId).catch(() => {})
334+
if (roleId) await api.deleteRole(roleId).catch(() => {})
335+
})
336+
337+
test('user with full analytics permissions can see all widget controls', async ({ page }) => {
338+
await loginWithCredentials(page, userEmail, userPassword)
339+
await page.goto('/')
340+
await page.waitForLoadState('networkidle')
341+
342+
// Should see Add Widget button
343+
const addButton = page.locator('button').filter({ hasText: /Add Widget/i })
344+
await expect(addButton).toBeVisible({ timeout: 10000 })
345+
346+
// Wait for widgets to load
347+
await page.waitForSelector('.card-depth', { timeout: 10000 })
348+
349+
// Hover over first widget
350+
const firstWidget = page.locator('.card-depth').first()
351+
await firstWidget.hover()
352+
353+
// Should see both edit and delete buttons
354+
const editButton = firstWidget.locator('button[title="Edit widget"]')
355+
await expect(editButton).toBeVisible()
356+
357+
const deleteButton = firstWidget.locator('button[title="Delete widget"]')
358+
await expect(deleteButton).toBeVisible()
359+
})
360+
})
361+
})

0 commit comments

Comments
 (0)