Skip to content

Commit be13de8

Browse files
committed
feat: add user role management and client addition functionality in project settings
1 parent a05fd12 commit be13de8

5 files changed

Lines changed: 192 additions & 57 deletions

File tree

src/components/apis/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface User {
1515
address?: AddressData | null;
1616
secondaryAddress?: AddressData | null;
1717
createdAt: string;
18+
role?: string;
1819
}
1920

2021
export interface LoginRequest {

src/components/apis/axiosInstance.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import axios from 'axios';
22

3-
// export const API_BASE_URL = "https://flowboard-backend.azurewebsites.net/";
3+
export const API_BASE_URL = "https://flowboard-backend.azurewebsites.net/";
44
// export const API_BASE_URL = "https://animated-space-fiesta-w6wpx564wqxh5vwj-5158.app.github.dev//";
5-
export const API_BASE_URL = "http://localhost:5158/";
5+
// export const API_BASE_URL = "http://localhost:5158/";
66

77
const axiosInstance = axios.create({
88
baseURL: API_BASE_URL,

src/components/apis/projects.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,9 @@ export const projectsApi = {
104104
const response = await axiosInstance.delete<Project>(`/api/projects/${projectId}/leave`);
105105
return response.data;
106106
},
107+
108+
updateProjectMemberPermissions: async (projectId: string, userId: string, role: string): Promise<Project> => {
109+
const response = await axiosInstance.put<Project>(`/api/projects/${projectId}/member/${userId}/permissions`, { role });
110+
return response.data;
111+
},
107112
};

src/components/headers/NavigationHeader.tsx

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Breadcrumb, BreadcrumbButton, BreadcrumbDivider, BreadcrumbItem, Button, Card, mergeClasses, Tooltip } from "@fluentui/react-components";
22
import { useLocation, useNavigate } from 'react-router-dom';
33
import { Folder20Regular, Board20Regular, TaskListSquareLtr20Regular, Settings20Regular, ChartMultiple20Regular } from '@fluentui/react-icons';
4-
import React from 'react';
4+
import React, { useEffect, useState } from 'react';
55
import { mainLayoutStyles } from "../styles/Styles";
6+
import { projectsApi } from "../apis/projects";
7+
import { useUser } from "../../hooks/useUser";
68

79
function titleCase(segment: string) {
810
// make a friendly label from a path segment
@@ -17,15 +19,14 @@ function titleCase(segment: string) {
1719
export default function NavigationHeader() {
1820
const location = useLocation();
1921
const navigate = useNavigate();
20-
const s = mainLayoutStyles()
22+
const s = mainLayoutStyles();
23+
const { user } = useUser();
24+
const [userProjectRole, setUserProjectRole] = useState<string | null>(null);
2125

2226
// Build path segments, remove empty and leading 'home'
2327
const rawSegments = location.pathname.split('/').filter(Boolean);
2428
const segments = rawSegments[0] === 'home' ? rawSegments.slice(1) : rawSegments;
2529

26-
// Do not render breadcrumb for base/home with no meaningful segments
27-
if (!segments || segments.length === 0) return null;
28-
2930
// Build cumulative paths (we want clicks to navigate to the correct /home/... route)
3031
const crumbs = segments.map((seg, i) => {
3132
// cumulative path should include /home as the root when navigating
@@ -38,6 +39,28 @@ export default function NavigationHeader() {
3839
const isInProject = segments.length >= 2 && segments[0] === 'project';
3940
const projectName = isInProject ? segments[1] : null;
4041

42+
// Fetch user's role in the current project
43+
useEffect(() => {
44+
const fetchProjectRole = async () => {
45+
if (!isInProject || !projectName || !user?.id) {
46+
setUserProjectRole(null);
47+
return;
48+
}
49+
try {
50+
const project = await projectsApi.getProjectById(projectName);
51+
const role = project.permissions?.[user.id] || 'Member';
52+
setUserProjectRole(role);
53+
} catch (err) {
54+
console.error('Failed to fetch project role:', err);
55+
setUserProjectRole(null);
56+
}
57+
};
58+
fetchProjectRole();
59+
}, [isInProject, projectName, user?.id]);
60+
61+
// Do not render breadcrumb for base/home with no meaningful segments
62+
if (!segments || segments.length === 0) return null;
63+
4164
// Construct navigation paths
4265
const kanbanPath = projectName ? `/home/project/${projectName}/kanban` : null;
4366
const tasksPath = projectName ? `/home/project/${projectName}/tasks` : null;
@@ -70,52 +93,70 @@ export default function NavigationHeader() {
7093
))}
7194
</Breadcrumb>
7295

73-
{/* Show Kanban, Tasks, Settings, and Analytics buttons when in a project context */}
96+
{/* Show buttons when in a project context - role-based visibility */}
7497
{isInProject && (
7598
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 'auto' }}>
76-
{kanbanPath && (
77-
<Tooltip content="Kanban Board" relationship="label">
78-
<Button
79-
appearance={isKanbanPage ? 'primary' : 'subtle'}
80-
icon={<Board20Regular />}
81-
onClick={() => navigate(kanbanPath)}
82-
>
83-
Kanban
84-
</Button>
85-
</Tooltip>
86-
)}
87-
{tasksPath && (
88-
<Tooltip content="Task List" relationship="label">
89-
<Button
90-
appearance={isTasksPage ? 'primary' : 'subtle'}
91-
icon={<TaskListSquareLtr20Regular />}
92-
onClick={() => navigate(tasksPath)}
93-
>
94-
Tasks
95-
</Button>
96-
</Tooltip>
97-
)}
98-
{analyticsPath && (
99-
<Tooltip content="Analytics Dashboard" relationship="label">
100-
<Button
101-
appearance={isAnalyticsPageProject ? 'primary' : 'subtle'}
102-
icon={<ChartMultiple20Regular />}
103-
onClick={() => navigate(analyticsPath)}
104-
>
105-
Analytics
106-
</Button>
107-
</Tooltip>
108-
)}
109-
{settingsPath && (
110-
<Tooltip content="Project Settings" relationship="label">
111-
<Button
112-
appearance={isSettingsPage ? 'primary' : 'subtle'}
113-
icon={<Settings20Regular />}
114-
onClick={() => navigate(settingsPath)}
115-
>
116-
Settings
117-
</Button>
118-
</Tooltip>
99+
{/* Clients see only Analytics */}
100+
{userProjectRole === 'Client' ? (
101+
analyticsPath && (
102+
<Tooltip content="Analytics Dashboard" relationship="label">
103+
<Button
104+
appearance={isAnalyticsPageProject ? 'primary' : 'subtle'}
105+
icon={<ChartMultiple20Regular />}
106+
onClick={() => navigate(analyticsPath)}
107+
>
108+
Analytics
109+
</Button>
110+
</Tooltip>
111+
)
112+
) : (
113+
<>
114+
{/* Non-client users see Kanban, Tasks, Analytics, and Settings */}
115+
{kanbanPath && (
116+
<Tooltip content="Kanban Board" relationship="label">
117+
<Button
118+
appearance={isKanbanPage ? 'primary' : 'subtle'}
119+
icon={<Board20Regular />}
120+
onClick={() => navigate(kanbanPath)}
121+
>
122+
Kanban
123+
</Button>
124+
</Tooltip>
125+
)}
126+
{tasksPath && (
127+
<Tooltip content="Task List" relationship="label">
128+
<Button
129+
appearance={isTasksPage ? 'primary' : 'subtle'}
130+
icon={<TaskListSquareLtr20Regular />}
131+
onClick={() => navigate(tasksPath)}
132+
>
133+
Tasks
134+
</Button>
135+
</Tooltip>
136+
)}
137+
{analyticsPath && (
138+
<Tooltip content="Analytics Dashboard" relationship="label">
139+
<Button
140+
appearance={isAnalyticsPageProject ? 'primary' : 'subtle'}
141+
icon={<ChartMultiple20Regular />}
142+
onClick={() => navigate(analyticsPath)}
143+
>
144+
Analytics
145+
</Button>
146+
</Tooltip>
147+
)}
148+
{settingsPath && (
149+
<Tooltip content="Project Settings" relationship="label">
150+
<Button
151+
appearance={isSettingsPage ? 'primary' : 'subtle'}
152+
icon={<Settings20Regular />}
153+
onClick={() => navigate(settingsPath)}
154+
>
155+
Settings
156+
</Button>
157+
</Tooltip>
158+
)}
159+
</>
119160
)}
120161
</div>
121162
)}

src/pages/project/ProjectPage.tsx

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
Input,
2323
Textarea,
2424
mergeClasses,
25+
Radio,
26+
RadioGroup,
2527
} from '@fluentui/react-components';
2628
import { AddCircle20Regular, FolderOpen24Regular, Edit20Regular, Checkmark20Regular, Dismiss20Regular } from '@fluentui/react-icons';
2729
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
@@ -112,7 +114,9 @@ export default function ProjectPage() {
112114
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
113115
const [allUsers, setAllUsers] = useState<User[]>([]);
114116
const [selectedUserId, setSelectedUserId] = useState<string>('');
117+
const [selectedRole, setSelectedRole] = useState<string>('Member');
115118
const [actionLoading, setActionLoading] = useState(false);
119+
const [addClientDialogOpen, setAddClientDialogOpen] = useState(false);
116120
const [refreshTrigger, setRefreshTrigger] = useState(0);
117121
const [isEditingProject, setIsEditingProject] = useState(false);
118122
const [editedName, setEditedName] = useState('');
@@ -244,8 +248,13 @@ export default function ProjectPage() {
244248
setActionLoading(true);
245249
try {
246250
await projectsApi.addProjectMembers(project.id, [selectedUserId]);
251+
// Assign role/permission if specified
252+
if (selectedRole !== 'Member' && selectedRole) {
253+
await projectsApi.updateProjectMemberPermissions(project.id, selectedUserId, selectedRole);
254+
}
247255
setInviteDialogOpen(false);
248256
setSelectedUserId('');
257+
setSelectedRole('Member');
249258
refreshProject();
250259
} catch (err) {
251260
console.error('Failed to invite member:', err);
@@ -255,6 +264,39 @@ export default function ProjectPage() {
255264
}
256265
};
257266

267+
const handleAddClient = async () => {
268+
if (!selectedUserId || !project?.id) return;
269+
setActionLoading(true);
270+
try {
271+
// Add user as client to the project
272+
await projectsApi.addProjectMembers(project.id, [selectedUserId]);
273+
// Set their role as Client
274+
await projectsApi.updateProjectMemberPermissions(project.id, selectedUserId, 'Client');
275+
setAddClientDialogOpen(false);
276+
setSelectedUserId('');
277+
refreshProject();
278+
} catch (err) {
279+
console.error('Failed to add client:', err);
280+
alert('Failed to add client');
281+
} finally {
282+
setActionLoading(false);
283+
}
284+
};
285+
286+
const openAddClientDialog = async () => {
287+
try {
288+
const users = await usersApi.getAllUsers();
289+
// Filter out current members and manager
290+
const memberIds = new Set([...(project?.teamMembers ?? []), project?.createdBy]);
291+
const availableUsers = users.filter((u) => !memberIds.has(u.id));
292+
setAllUsers(availableUsers);
293+
setAddClientDialogOpen(true);
294+
} catch (err) {
295+
console.error('Failed to fetch users:', err);
296+
alert('Failed to load users');
297+
}
298+
};
299+
258300
const handleRemoveMember = async (memberId: string) => {
259301
if (!project?.id) return;
260302
if (!confirm('Remove this member from the project?')) return;
@@ -500,36 +542,47 @@ export default function ProjectPage() {
500542
</Table>
501543
)}
502544
{isOwner && (
503-
<div>
545+
<div style={{ display: 'flex', gap: tokens.spacingHorizontalS }}>
504546
<Button appearance="secondary" icon={<AddCircle20Regular />} onClick={openInviteDialog}>
505547
Invite Member
506548
</Button>
549+
<Button appearance="primary" icon={<AddCircle20Regular />} onClick={openAddClientDialog}>
550+
Add Client
551+
</Button>
507552
</div>
508553
)}
509554
</section>
510555
</>
511556
)}
512557

513558
{/* Invite Member Dialog */}
514-
<Dialog open={inviteDialogOpen} onOpenChange={(_, data) => setInviteDialogOpen(data.open)}>
559+
<Dialog open={inviteDialogOpen} onOpenChange={(_, data) => { setInviteDialogOpen(data.open); if (!data.open) { setSelectedUserId(''); setSelectedRole('Member'); } }}>
515560
<DialogSurface>
516561
<DialogBody>
517562
<DialogTitle>Invite Member to Project</DialogTitle>
518-
<DialogContent>
563+
<DialogContent style={{ display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalM }}>
519564
<Dropdown
520565
placeholder="Select a user"
521566
value={allUsers.find((u) => u.id === selectedUserId)?.userName || ''}
522567
onOptionSelect={(_, data) => setSelectedUserId(data.optionValue || '')}
523568
>
524569
{allUsers.map((user) => (
525-
<Option key={user.id} value={user.id} text={`${user.userName} (${user.email})`}>
526-
{user.userName} ({user.email})
570+
<Option key={user.id} value={user.id} text={`${user.firstName} ${user.lastName} (${user.email})`}>
571+
{user.firstName} {user.lastName} ({user.email})
527572
</Option>
528573
))}
529574
</Dropdown>
575+
<div>
576+
<label style={{ display: 'block', marginBottom: tokens.spacingVerticalXS, fontWeight: tokens.fontWeightSemibold }}>Role</label>
577+
<RadioGroup value={selectedRole} onChange={(_, data) => setSelectedRole(data.value)}>
578+
<Radio value="Member" label="Member (View and edit tasks)" />
579+
<Radio value="Editor" label="Editor (Full project edit access)" />
580+
<Radio value="Viewer" label="Viewer (Read-only access)" />
581+
</RadioGroup>
582+
</div>
530583
</DialogContent>
531584
<DialogActions>
532-
<Button appearance="secondary" onClick={() => setInviteDialogOpen(false)}>
585+
<Button appearance="secondary" onClick={() => { setInviteDialogOpen(false); setSelectedUserId(''); setSelectedRole('Member'); }}>
533586
Cancel
534587
</Button>
535588
<Button appearance="primary" onClick={handleInviteMember} disabled={!selectedUserId || actionLoading}>
@@ -540,6 +593,41 @@ export default function ProjectPage() {
540593
</DialogSurface>
541594
</Dialog>
542595

596+
{/* Add Client Dialog */}
597+
<Dialog open={addClientDialogOpen} onOpenChange={(_, data) => { setAddClientDialogOpen(data.open); if (!data.open) setSelectedUserId(''); }}>
598+
<DialogSurface>
599+
<DialogBody>
600+
<DialogTitle>Add Client to Project</DialogTitle>
601+
<DialogContent>
602+
<Dropdown
603+
placeholder="Select a client"
604+
value={allUsers.find((u) => u.id === selectedUserId)?.userName || ''}
605+
onOptionSelect={(_, data) => setSelectedUserId(data.optionValue || '')}
606+
>
607+
{allUsers.map((user) => (
608+
<Option key={user.id} value={user.id} text={`${user.firstName} ${user.lastName} (${user.email})`}>
609+
{user.firstName} {user.lastName} ({user.email})
610+
</Option>
611+
))}
612+
</Dropdown>
613+
<div style={{ marginTop: tokens.spacingVerticalM, padding: tokens.spacingVerticalS, backgroundColor: tokens.colorNeutralBackground3, borderRadius: tokens.borderRadiusMedium }}>
614+
<p style={{ margin: 0, fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2 }}>
615+
Clients will have limited access and can only view analytics for this project.
616+
</p>
617+
</div>
618+
</DialogContent>
619+
<DialogActions>
620+
<Button appearance="secondary" onClick={() => { setAddClientDialogOpen(false); setSelectedUserId(''); }}>
621+
Cancel
622+
</Button>
623+
<Button appearance="primary" onClick={handleAddClient} disabled={!selectedUserId || actionLoading}>
624+
{actionLoading ? 'Adding...' : 'Add Client'}
625+
</Button>
626+
</DialogActions>
627+
</DialogBody>
628+
</DialogSurface>
629+
</Dialog>
630+
543631
{/* Delete Project Dialog */}
544632
<Dialog open={deleteDialogOpen} onOpenChange={(_, data) => setDeleteDialogOpen(data.open)}>
545633
<DialogSurface>

0 commit comments

Comments
 (0)