Skip to content

Commit fba97cf

Browse files
Add Join/Leave button to project header and restrict CLI section to members
1 parent fea6787 commit fba97cf

File tree

2 files changed

+167
-40
lines changed

2 files changed

+167
-40
lines changed

frontend/src/pages/Project/Details/Settings/index.tsx

Lines changed: 46 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,21 @@ export const ProjectSettings: React.FC = () => {
6161
const { data, isLoading, error } = useGetProjectQuery({ name: paramProjectName });
6262

6363
useEffect(() => {
64+
// Only throw router exception for actual 404 errors, not permission errors
65+
// For public projects, non-members should still be able to view project details
6466
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
6567
// @ts-ignore
6668
if (error?.status === 404) {
6769
riseRouterException();
6870
}
71+
// Don't throw exceptions for other errors (like 403) as they might be permission-related
72+
// but the project might still be viewable if it's public
6973
}, [error]);
7074

75+
// Check if current user is a member of the project
76+
const currentUserRole = data ? getProjectRoleByUserName(data, currentUser?.username ?? '') : null;
77+
const isProjectMember = currentUserRole !== null;
78+
7179
const currentOwner = {
7280
label: data?.owner.username,
7381
value: data?.owner.username,
@@ -164,42 +172,44 @@ export const ProjectSettings: React.FC = () => {
164172
<>
165173
{data && backendsData && gatewaysData && (
166174
<SpaceBetween size="l">
167-
<Container
168-
header={
169-
<Header variant="h2" info={<InfoLink onFollow={() => openHelpPanel(CLI_INFO)} />}>
170-
{t('projects.edit.cli')}
171-
</Header>
172-
}
173-
>
174-
<SpaceBetween size="s">
175-
<Box variant="p" color="text-body-secondary">
176-
Run the following commands to set up the CLI for this project
177-
</Box>
178-
179-
<div className={styles.codeWrapper}>
180-
<Hotspot hotspotId={HotspotIds.CONFIGURE_CLI_COMMAND}>
181-
<Code className={styles.code}>{configCliCommand}</Code>
182-
183-
<div className={styles.copy}>
184-
<Popover
185-
dismissButton={false}
186-
position="top"
187-
size="small"
188-
triggerType="custom"
189-
content={<StatusIndicator type="success">{t('common.copied')}</StatusIndicator>}
190-
>
191-
<Button
192-
formAction="none"
193-
iconName="copy"
194-
variant="normal"
195-
onClick={copyCliCommand}
196-
/>
197-
</Popover>
198-
</div>
199-
</Hotspot>
200-
</div>
201-
</SpaceBetween>
202-
</Container>
175+
{isProjectMember && (
176+
<Container
177+
header={
178+
<Header variant="h2" info={<InfoLink onFollow={() => openHelpPanel(CLI_INFO)} />}>
179+
{t('projects.edit.cli')}
180+
</Header>
181+
}
182+
>
183+
<SpaceBetween size="s">
184+
<Box variant="p" color="text-body-secondary">
185+
Run the following commands to set up the CLI for this project
186+
</Box>
187+
188+
<div className={styles.codeWrapper}>
189+
<Hotspot hotspotId={HotspotIds.CONFIGURE_CLI_COMMAND}>
190+
<Code className={styles.code}>{configCliCommand}</Code>
191+
192+
<div className={styles.copy}>
193+
<Popover
194+
dismissButton={false}
195+
position="top"
196+
size="small"
197+
triggerType="custom"
198+
content={<StatusIndicator type="success">{t('common.copied')}</StatusIndicator>}
199+
>
200+
<Button
201+
formAction="none"
202+
iconName="copy"
203+
variant="normal"
204+
onClick={copyCliCommand}
205+
/>
206+
</Popover>
207+
</div>
208+
</Hotspot>
209+
</div>
210+
</SpaceBetween>
211+
</Container>
212+
)}
203213

204214
<BackendsTable
205215
backends={backendsData}

frontend/src/pages/Project/Details/index.tsx

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,131 @@
1-
import React from 'react';
2-
import { Outlet, useParams } from 'react-router-dom';
1+
import React, { useMemo } from 'react';
2+
import { Outlet, useNavigate, useParams } from 'react-router-dom';
3+
import { useTranslation } from 'react-i18next';
34

4-
import { ContentLayout, DetailsHeader } from 'components';
5+
import { Button, ContentLayout, DetailsHeader } from 'components';
6+
7+
import { useAppSelector, useNotifications } from 'hooks';
8+
import { selectUserData } from 'App/slice';
9+
import { ROUTES } from 'routes';
10+
import { useGetProjectQuery, useAddProjectMemberMutation, useRemoveProjectMemberMutation } from 'services/project';
11+
import { getProjectRoleByUserName } from '../utils';
512

613
export const ProjectDetails: React.FC = () => {
14+
const { t } = useTranslation();
715
const params = useParams();
16+
const navigate = useNavigate();
817
const paramProjectName = params.projectName ?? '';
18+
const [pushNotification] = useNotifications();
19+
const userData = useAppSelector(selectUserData);
20+
21+
const { data: project } = useGetProjectQuery({ name: paramProjectName });
22+
const [addMember, { isLoading: isAdding }] = useAddProjectMemberMutation();
23+
const [removeMember, { isLoading: isRemoving }] = useRemoveProjectMemberMutation();
24+
25+
const currentUserRole = useMemo(() => {
26+
if (!userData?.username || !project) return null;
27+
return getProjectRoleByUserName(project, userData.username);
28+
}, [project, userData?.username]);
29+
30+
const isProjectOwner = useMemo(() => {
31+
return userData?.username === project?.owner.username;
32+
}, [userData?.username, project?.owner.username]);
33+
34+
const isMember = currentUserRole !== null;
35+
const isMemberActionLoading = isAdding || isRemoving;
36+
37+
const handleJoinProject = async () => {
38+
if (!userData?.username || !project) return;
39+
40+
try {
41+
await addMember({
42+
project_name: project.project_name,
43+
username: userData.username,
44+
project_role: 'user',
45+
}).unwrap();
46+
47+
pushNotification({
48+
type: 'success',
49+
content: t('projects.join_success'),
50+
});
51+
} catch (error) {
52+
console.error('Failed to join project:', error);
53+
pushNotification({
54+
type: 'error',
55+
content: t('projects.join_error'),
56+
});
57+
}
58+
};
59+
60+
const handleLeaveProject = async () => {
61+
if (!userData?.username || !project) return;
62+
63+
try {
64+
await removeMember({
65+
project_name: project.project_name,
66+
username: userData.username,
67+
}).unwrap();
68+
69+
pushNotification({
70+
type: 'success',
71+
content: t('projects.leave_success'),
72+
});
73+
74+
// Redirect to project list after successfully leaving
75+
navigate(ROUTES.PROJECT.LIST);
76+
} catch (error) {
77+
console.error('Failed to leave project:', error);
78+
pushNotification({
79+
type: 'error',
80+
content: t('projects.leave_error'),
81+
});
82+
}
83+
};
84+
85+
const renderJoinLeaveButton = () => {
86+
// Only show button if user is authenticated and project is loaded
87+
if (!userData?.username || !project) return null;
88+
89+
if (!isMember) {
90+
return (
91+
<Button
92+
onClick={handleJoinProject}
93+
disabled={isMemberActionLoading}
94+
variant="primary"
95+
>
96+
{isMemberActionLoading ? t('common.loading') : t('projects.join')}
97+
</Button>
98+
);
99+
} else {
100+
// Prevent owners and admins from leaving their projects
101+
const canLeave = !isProjectOwner && currentUserRole !== 'admin';
102+
103+
return (
104+
<Button
105+
onClick={handleLeaveProject}
106+
disabled={isMemberActionLoading || !canLeave}
107+
variant="normal"
108+
>
109+
{!canLeave
110+
? t('projects.owner_cannot_leave')
111+
: isMemberActionLoading
112+
? t('common.loading')
113+
: t('projects.leave')
114+
}
115+
</Button>
116+
);
117+
}
118+
};
9119

10120
return (
11-
<ContentLayout header={<DetailsHeader title={paramProjectName} />}>
121+
<ContentLayout
122+
header={
123+
<DetailsHeader
124+
title={paramProjectName}
125+
actionButtons={renderJoinLeaveButton()}
126+
/>
127+
}
128+
>
12129
<Outlet />
13130
</ContentLayout>
14131
);

0 commit comments

Comments
 (0)