Skip to content

Commit 42a508f

Browse files
authored
feat(permissions): implement user permissions management system (#99)
Implement comprehensive user permissions management for projects with role-based access control. - Add user management API endpoints (add/update/remove users) - Create user permissions UI components (table, form, matrix) - Implement route guards and conditional UI for settings access - Simplify from project/committee scope to project-wide roles - Update E2E tests for permission-based settings visibility JIRA: LFXV2-548, LFXV2-549, LFXV2-550, LFXV2-551, LFXV2-552, LFXV2-553, LFXV2-554 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent caba2c9 commit 42a508f

File tree

20 files changed

+616
-528
lines changed

20 files changed

+616
-528
lines changed

apps/lfx-one/e2e/project-dashboard.spec.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,21 @@ test.describe('Project Dashboard', () => {
8282
await expect(page.getByTestId('menu-item').filter({ hasText: 'Meetings' })).toBeVisible();
8383
await expect(page.getByTestId('menu-item').filter({ hasText: 'Committees' })).toBeVisible();
8484
// await expect(page.getByTestId('menu-item').filter({ hasText: 'Mailing Lists' })).toBeVisible();
85-
// await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).toBeVisible();
85+
await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).toBeVisible();
86+
});
87+
88+
test('should display Settings menu for users with write permissions', async ({ page }) => {
89+
// Settings should be visible in both desktop and mobile views for users with write permissions
90+
const viewport = page.viewportSize();
91+
const isMobile = viewport && viewport.width < 768;
92+
93+
if (isMobile) {
94+
// On mobile, Settings should be visible as mobile-menu-item
95+
await expect(page.getByTestId('mobile-menu-item').filter({ hasText: 'Settings' })).toBeVisible();
96+
} else {
97+
// On desktop, Settings should be visible as menu-item
98+
await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).toBeVisible();
99+
}
86100
});
87101
});
88102

@@ -425,8 +439,22 @@ test.describe('Without Write Permissions', () => {
425439
await expect(page.getByTestId('menu-item').filter({ hasText: 'Committees' })).toBeVisible();
426440
// await expect(page.getByTestId('menu-item').filter({ hasText: 'Mailing Lists' })).toBeVisible();
427441

428-
// Settings tab should also be accessible (though it may have limited functionality)
429-
// await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).toBeVisible();
442+
// Settings tab should NOT be visible for users without write permissions
443+
await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).not.toBeVisible();
444+
});
445+
446+
test('should NOT display Settings menu for users without write permissions', async ({ page }) => {
447+
// Settings should NOT be visible in either desktop or mobile views for users without write permissions
448+
const viewport = page.viewportSize();
449+
const isMobile = viewport && viewport.width < 768;
450+
451+
if (isMobile) {
452+
// On mobile, Settings should NOT be visible as mobile-menu-item
453+
await expect(page.getByTestId('mobile-menu-item').filter({ hasText: 'Settings' })).not.toBeVisible();
454+
} else {
455+
// On desktop, Settings should NOT be visible as menu-item
456+
await expect(page.getByTestId('menu-item').filter({ hasText: 'Settings' })).not.toBeVisible();
457+
}
430458
});
431459
});
432460

@@ -455,6 +483,9 @@ test.describe('Without Write Permissions', () => {
455483
// Should only have 2 menu items on mobile for read-only users
456484
const quickActionsSection = page.getByText('Quick Actions').locator('..');
457485
await expect(quickActionsSection.getByRole('menuitem')).toHaveCount(2);
486+
487+
// Settings should NOT be visible on mobile for read-only users
488+
await expect(page.getByTestId('mobile-menu-item').filter({ hasText: 'Settings' })).not.toBeVisible();
458489
});
459490
});
460491
});

apps/lfx-one/src/app/layouts/project-layout/project-layout.component.html

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,31 +56,33 @@ <h1 class="text-2xl font-display font-semibold text-gray-900 mb-3">
5656
{{ menu.label }}
5757
</a>
5858
}
59-
<div class="flex md:hidden">
59+
@if (hasWriterAccess()) {
60+
<div class="flex md:hidden">
61+
<a
62+
routerLink="/project/{{ projectSlug() }}/settings"
63+
class="pill"
64+
routerLinkActive="bg-blue-50 text-blue-600 border-0 block md:hidden"
65+
data-testid="mobile-menu-item"
66+
[routerLinkActiveOptions]="{ exact: true }">
67+
<i class="fa-light fa-gear"></i>
68+
<span>Settings</span>
69+
</a>
70+
</div>
71+
}
72+
</div>
73+
@if (hasWriterAccess()) {
74+
<div class="items-center gap-3 hidden md:flex">
6075
<a
6176
routerLink="/project/{{ projectSlug() }}/settings"
6277
class="pill"
63-
routerLinkActive="bg-blue-50 text-blue-600 border-0 block md:hidden"
64-
data-testid="mobile-menu-item"
78+
routerLinkActive="bg-blue-50 text-blue-600 border-0"
79+
data-testid="menu-item"
6580
[routerLinkActiveOptions]="{ exact: true }">
6681
<i class="fa-light fa-gear"></i>
6782
<span>Settings</span>
6883
</a>
6984
</div>
70-
</div>
71-
<!-- TODO: Add settings menu item back in
72-
<div class="items-center gap-3 hidden md:flex">
73-
<a
74-
routerLink="/project/{{ projectSlug() }}/settings"
75-
class="pill"
76-
routerLinkActive="bg-blue-50 text-blue-600 border-0"
77-
data-testid="menu-item"
78-
[routerLinkActiveOptions]="{ exact: true }">
79-
<i class="fa-light fa-gear"></i>
80-
<span>Settings</span>
81-
</a>
82-
</div>
83-
-->
85+
}
8486
</div>
8587
</div>
8688
</div>

apps/lfx-one/src/app/layouts/project-layout/project-layout.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class ProjectLayoutComponent {
4444
public readonly categoryLabel = computed(() => this.project()?.slug || '');
4545
public readonly projectSlug = computed(() => this.project()?.slug || '');
4646
public readonly projectLogo = computed(() => this.project()?.logo_url || '');
47+
public readonly hasWriterAccess = computed(() => this.project()?.writer === true);
4748
public readonly breadcrumbItems = input<MenuItem[]>([
4849
{
4950
label: 'All Projects',

apps/lfx-one/src/app/modules/project/project.routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import { Routes } from '@angular/router';
55

6+
import { writerGuard } from '../../shared/guards/writer.guard';
7+
68
export const PROJECT_ROUTES: Routes = [
79
{
810
path: '',
@@ -26,6 +28,7 @@ export const PROJECT_ROUTES: Routes = [
2628
{
2729
path: 'settings',
2830
loadComponent: () => import('./settings/settings-dashboard/settings-dashboard.component').then((m) => m.SettingsDashboardComponent),
31+
canActivate: [writerGuard],
2932
data: { preload: false }, // Settings accessed less frequently, don't preload
3033
},
3134
];

apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.html

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
<div class="space-y-4">
55
<!-- Key Points -->
66
<div class="bg-blue-50 border border-blue-200 rounded p-3">
7-
<p class="text-xs text-blue-800">
8-
<strong>Key:</strong> Project permissions apply to all resources. Committee permissions are limited to assigned committees and their associated meetings
9-
and/or mailing lists only.
10-
</p>
7+
<p class="text-xs text-blue-800"><strong>Key:</strong> All permissions apply to the entire project including committees, meetings, and mailing lists.</p>
118
</div>
129

1310
<!-- Compact Permission Matrix -->

apps/lfx-one/src/app/modules/project/settings/components/permissions-matrix/permissions-matrix.component.ts

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,36 +28,6 @@ export class PermissionsMatrixComponent {
2828
level: 'Manage',
2929
description: 'Manage all project resources',
3030
capabilities: ['Manage project, committees, meetings, mailing lists'],
31-
badge: {
32-
color: 'text-blue-800',
33-
bgColor: 'bg-blue-100',
34-
},
35-
},
36-
{
37-
scope: 'Committee',
38-
level: 'View',
39-
description: 'View specific committee only, including meetings and mailing lists that are associated with the committees.',
40-
capabilities: [
41-
'View project (limited to committees)',
42-
'View assigned committees',
43-
'View meetings associated with the committees',
44-
'View mailing lists associated with the committees',
45-
],
46-
badge: {
47-
color: 'text-green-800',
48-
bgColor: 'bg-green-100',
49-
},
50-
},
51-
{
52-
scope: 'Committee',
53-
level: 'Manage',
54-
description: 'Manage specific committee only, including meetings and mailing lists that are associated with the committees.',
55-
capabilities: [
56-
'View project (limited to committees)',
57-
'Manage assigned committees',
58-
'Manage meetings associated with the committees',
59-
'Manage mailing lists associated with the committees',
60-
],
6131
badge: {
6232
color: 'text-green-800',
6333
bgColor: 'bg-green-100',

apps/lfx-one/src/app/modules/project/settings/components/user-form/user-form.component.html

Lines changed: 22 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -2,95 +2,34 @@
22
<!-- SPDX-License-Identifier: MIT -->
33

44
<form [formGroup]="form()" (ngSubmit)="onSubmit()" class="space-y-6">
5-
<!-- Basic Information Section -->
5+
<!-- User Information Section -->
66
<div class="flex flex-col gap-3">
7-
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
8-
<!-- First Name -->
9-
<div>
10-
<label for="first-name" class="block text-sm font-medium text-gray-700 mb-1"> First Name <span class="text-red-500">*</span> </label>
11-
<lfx-input-text
12-
size="small"
13-
[form]="form()"
14-
control="first_name"
15-
id="first-name"
16-
placeholder="Enter first name"
17-
styleClass="w-full"
18-
data-testid="settings-user-form-first-name"></lfx-input-text>
19-
@if (form().get('first_name')?.errors?.['required'] && form().get('first_name')?.touched) {
20-
<p class="mt-1 text-xs text-red-600">First name is required</p>
21-
}
22-
</div>
23-
24-
<!-- Last Name -->
25-
<div>
26-
<label for="last-name" class="block text-sm font-medium text-gray-700 mb-1"> Last Name <span class="text-red-500">*</span> </label>
27-
<lfx-input-text
28-
size="small"
29-
[form]="form()"
30-
control="last_name"
31-
id="last-name"
32-
placeholder="Enter last name"
33-
styleClass="w-full"
34-
data-testid="settings-user-form-last-name"></lfx-input-text>
35-
@if (form().get('last_name')?.errors?.['required'] && form().get('last_name')?.touched) {
36-
<p class="mt-1 text-xs text-red-600">Last name is required</p>
37-
}
38-
</div>
39-
</div>
40-
41-
<!-- Email Address -->
7+
<!-- Username -->
428
<div>
43-
<label for="email" class="block text-sm font-medium text-gray-700 mb-1"> Email Address <span class="text-red-500">*</span> </label>
9+
<label for="username" class="block text-sm font-medium text-gray-700 mb-1"> Username <span class="text-red-500">*</span> </label>
4410
<lfx-input-text
4511
size="small"
4612
[form]="form()"
47-
control="email"
48-
id="email"
49-
placeholder="Enter email address"
13+
control="username"
14+
id="username"
15+
placeholder="Enter username"
5016
styleClass="w-full"
51-
data-testid="settings-user-form-email"></lfx-input-text>
52-
@if (form().get('email')?.errors?.['required'] && form().get('email')?.touched) {
53-
<p class="mt-1 text-xs text-red-600">Email address is required</p>
54-
}
55-
@if (form().get('email')?.errors?.['email'] && form().get('email')?.touched) {
56-
<p class="mt-1 text-xs text-red-600">Please enter a valid email address</p>
17+
data-testid="settings-user-form-username"></lfx-input-text>
18+
@if (form().get('username')?.errors?.['required'] && form().get('username')?.touched) {
19+
<p class="mt-1 text-xs text-red-600">Username is required</p>
5720
}
5821
</div>
5922
</div>
6023

6124
<!-- Permission Settings Section -->
6225
<div class="flex flex-col gap-3">
63-
<!-- Permission Scope -->
64-
<div>
65-
<div class="flex items-center gap-2 mb-3">
66-
<label class="block text-sm font-medium text-gray-700">Permission Scope</label>
67-
<i
68-
class="fa-light fa-info-circle text-gray-400 cursor-help"
69-
[pTooltip]="scopeTooltip"
70-
tooltipPosition="right"
71-
[escape]="false"
72-
tooltipStyleClass="text-xs max-w-sm"></i>
73-
</div>
74-
<div class="flex flex-col gap-2">
75-
@for (option of permissionScopeOptions; track option.value) {
76-
<lfx-radio-button
77-
[form]="form()"
78-
control="permission_scope"
79-
name="permission_scope"
80-
[inputId]="'scope-' + option.value"
81-
[value]="option.value"
82-
[label]="option.label"></lfx-radio-button>
83-
}
84-
</div>
85-
</div>
86-
87-
<!-- Permission Level -->
26+
<!-- Role Selection -->
8827
<div>
8928
<div class="flex items-center gap-2 mb-3">
90-
<label class="block text-sm font-medium text-gray-700">Permission Level</label>
29+
<label class="block text-sm font-medium text-gray-700">Role</label>
9130
<i
9231
class="fa-light fa-info-circle text-gray-400 cursor-help"
93-
[pTooltip]="levelTooltip"
32+
[pTooltip]="roleTooltip"
9433
tooltipPosition="right"
9534
[escape]="false"
9635
tooltipStyleClass="text-xs max-w-sm"></i>
@@ -99,50 +38,22 @@
9938
@for (option of permissionLevelOptions; track option.value) {
10039
<lfx-radio-button
10140
[form]="form()"
102-
control="permission_level"
103-
name="permission_level"
104-
[inputId]="'level-' + option.value"
41+
control="role"
42+
name="role"
43+
[inputId]="'role-' + option.value"
10544
[value]="option.value"
10645
[label]="option.label"></lfx-radio-button>
10746
}
10847
</div>
10948
</div>
11049

111-
<!-- Committee Selection (only when scope is committee) -->
112-
@if (form().get('permission_scope')?.value === 'committee') {
113-
<div>
114-
<label for="committee-ids" class="block text-sm font-medium text-gray-700 mb-1">Select Committees <span class="text-red-500">*</span></label>
115-
<p class="text-xs text-gray-500 mb-2">Choose which committees this user can access</p>
116-
<lfx-multi-select
117-
[form]="form()"
118-
control="committee_ids"
119-
[options]="committees()"
120-
size="small"
121-
placeholder="Select committees"
122-
appendTo="body"
123-
id="committee-ids"
124-
data-testid="settings-user-form-committee-ids"
125-
styleClass="w-full"></lfx-multi-select>
126-
@if (form().get('committee_ids')?.errors?.['required'] && form().get('committee_ids')?.touched) {
127-
<p class="mt-1 text-xs text-red-600">At least one committee must be selected</p>
128-
}
129-
</div>
130-
}
131-
13250
<!-- Permission Summary -->
13351
<div class="bg-gray-50 p-3 rounded-md text-sm">
13452
<div class="font-medium text-gray-700 mb-1">Permission Summary:</div>
135-
@if (form().get('permission_scope')?.value === 'project') {
136-
<div class="text-gray-600">
137-
<strong>{{ form().get('permission_level')?.value === 'read' ? 'View-only' : 'Full' }}</strong> access to <strong>entire project</strong> including all
138-
committees, meetings, and mailing lists.
139-
</div>
140-
} @else {
141-
<div class="text-gray-600">
142-
<strong>{{ form().get('permission_level')?.value === 'read' ? 'View-only' : 'Full' }}</strong> access to <strong>selected committees</strong> and
143-
their associated meetings and mailing lists only.
144-
</div>
145-
}
53+
<div class="text-gray-600">
54+
<strong>{{ form().get('role')?.value === 'view' ? 'View-only' : 'Full management' }}</strong> access to the <strong>entire project</strong> including
55+
all committees, meetings, and mailing lists.
56+
</div>
14657
</div>
14758
</div>
14859

@@ -166,16 +77,9 @@
16677
</div>
16778
</form>
16879

169-
<ng-template #scopeTooltip>
170-
<div class="flex flex-col">
171-
<div><strong>Project:</strong> Access to entire project including all committees, meetings, and mailing lists</div>
172-
<div><strong>Committee:</strong> Access limited to specific committees and their associated content only</div>
173-
</div>
174-
</ng-template>
175-
176-
<ng-template #levelTooltip>
80+
<ng-template #roleTooltip>
17781
<div class="flex flex-col">
178-
<div><strong>View:</strong> Read-only access to view and browse content</div>
179-
<div><strong>Manage:</strong> Full access to create, edit, delete, and manage content</div>
82+
<div><strong>View:</strong> Read-only access to view and browse all project content</div>
83+
<div><strong>Manage:</strong> Full access to create, edit, delete, and manage all project content</div>
18084
</div>
18185
</ng-template>

0 commit comments

Comments
 (0)