|
1 |
| -import { cx } from "@emotion/css"; |
2 |
| -import type { Interpolation, Theme } from "@emotion/react"; |
3 |
| -import AddIcon from "@mui/icons-material/Add"; |
4 |
| -import SettingsIcon from "@mui/icons-material/Settings"; |
5 |
| -import type { FC, ReactNode } from "react"; |
| 1 | +import type { FC } from "react"; |
6 | 2 | import { useQuery } from "react-query";
|
7 |
| -import { Link, NavLink, useLocation, useParams } from "react-router-dom"; |
| 3 | +import { useParams } from "react-router-dom"; |
8 | 4 | import { organizationPermissions } from "api/queries/organizations";
|
9 |
| -import type { Organization } from "api/typesGenerated"; |
10 |
| -import { Loader } from "components/Loader/Loader"; |
11 |
| -import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; |
12 |
| -import { Stack } from "components/Stack/Stack"; |
13 |
| -import { UserAvatar } from "components/UserAvatar/UserAvatar"; |
14 | 5 | import { useAuthenticated } from "contexts/auth/RequireAuth";
|
15 |
| -import { type ClassName, useClassName } from "hooks/useClassName"; |
16 |
| -import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility"; |
17 |
| -import { AUDIT_LINK, USERS_LINK, withFilter } from "modules/navigation"; |
18 | 6 | import { useOrganizationSettings } from "./ManagementSettingsLayout";
|
19 |
| - |
| 7 | +import { SidebarView } from "./SidebarView"; |
| 8 | + |
| 9 | +/** |
| 10 | + * A combined deployment settings and organization menu. |
| 11 | + * |
| 12 | + * This should only be used with multi-org support. If multi-org support is |
| 13 | + * disabled or not licensed, this is the wrong sidebar to use. See |
| 14 | + * DeploySettingsPage/Sidebar instead. |
| 15 | + */ |
20 | 16 | export const Sidebar: FC = () => {
|
21 | 17 | const { permissions } = useAuthenticated();
|
22 | 18 | const { organizations } = useOrganizationSettings();
|
23 |
| - const { organization } = useParams() as { organization?: string }; |
24 |
| - const { multiple_organizations: organizationsEnabled } = |
25 |
| - useFeatureVisibility(); |
26 |
| - |
27 |
| - let organizationName = organization; |
28 |
| - if (location.pathname === "/organizations" && organizations) { |
29 |
| - organizationName = getOrganizationNameByDefault(organizations); |
30 |
| - } |
31 |
| - |
32 |
| - // TODO: Do something nice to scroll to the active org. |
33 |
| - |
34 |
| - return ( |
35 |
| - <BaseSidebar> |
36 |
| - {organizationsEnabled && ( |
37 |
| - <header css={styles.sidebarHeader}>Deployment</header> |
38 |
| - )} |
39 |
| - <DeploymentSettingsNavigation |
40 |
| - organizationsEnabled={organizationsEnabled} |
41 |
| - /> |
42 |
| - {organizationsEnabled && |
43 |
| - (organizations ? ( |
44 |
| - <> |
45 |
| - <header css={styles.sidebarHeader}>Organizations</header> |
46 |
| - {permissions.createOrganization && ( |
47 |
| - <SidebarNavItem |
48 |
| - active="auto" |
49 |
| - href="/organizations/new" |
50 |
| - icon={<AddIcon />} |
51 |
| - > |
52 |
| - New organization |
53 |
| - </SidebarNavItem> |
54 |
| - )} |
55 |
| - {organizations.map((org) => ( |
56 |
| - <OrganizationSettingsNavigation |
57 |
| - key={org.id} |
58 |
| - organization={org} |
59 |
| - active={org.name === organizationName} |
60 |
| - /> |
61 |
| - ))} |
62 |
| - </> |
63 |
| - ) : ( |
64 |
| - <Loader /> |
65 |
| - ))} |
66 |
| - </BaseSidebar> |
| 19 | + const { organization: organizationName } = useParams() as { |
| 20 | + organization?: string; |
| 21 | + }; |
| 22 | + |
| 23 | + // If there is no organization name, the settings page will load, and it will |
| 24 | + // redirect to the default organization, so eventually there will always be an |
| 25 | + // organization name. |
| 26 | + const activeOrganization = organizations?.find( |
| 27 | + (o) => o.name === organizationName, |
67 | 28 | );
|
68 |
| -}; |
69 |
| - |
70 |
| -interface DeploymentSettingsNavigationProps { |
71 |
| - organizationsEnabled?: boolean; |
72 |
| -} |
73 |
| - |
74 |
| -const DeploymentSettingsNavigation: FC<DeploymentSettingsNavigationProps> = ({ |
75 |
| - organizationsEnabled, |
76 |
| -}) => { |
77 |
| - const location = useLocation(); |
78 |
| - const active = location.pathname.startsWith("/deployment"); |
79 |
| - const { permissions } = useAuthenticated(); |
80 |
| - |
81 |
| - return ( |
82 |
| - <div css={{ paddingBottom: 12 }}> |
83 |
| - <SidebarNavItem |
84 |
| - active={active} |
85 |
| - href={ |
86 |
| - permissions.viewDeploymentValues |
87 |
| - ? "/deployment/general" |
88 |
| - : "/deployment/workspace-proxies" |
89 |
| - } |
90 |
| - // 24px matches the width of the organization icons, and the component is smart enough |
91 |
| - // to keep the icon itself square. It looks too big if it's 24x24. |
92 |
| - icon={<SettingsIcon css={{ width: 24, height: 20 }} />} |
93 |
| - > |
94 |
| - Deployment |
95 |
| - </SidebarNavItem> |
96 |
| - {active && ( |
97 |
| - <Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}> |
98 |
| - {permissions.viewDeploymentValues && ( |
99 |
| - <SidebarNavSubItem href="general">General</SidebarNavSubItem> |
100 |
| - )} |
101 |
| - {permissions.viewDeploymentValues && ( |
102 |
| - <SidebarNavSubItem href="licenses">Licenses</SidebarNavSubItem> |
103 |
| - )} |
104 |
| - {permissions.editDeploymentValues && ( |
105 |
| - <SidebarNavSubItem href="appearance">Appearance</SidebarNavSubItem> |
106 |
| - )} |
107 |
| - {permissions.viewDeploymentValues && ( |
108 |
| - <SidebarNavSubItem href="userauth"> |
109 |
| - User Authentication |
110 |
| - </SidebarNavSubItem> |
111 |
| - )} |
112 |
| - {permissions.viewDeploymentValues && ( |
113 |
| - <SidebarNavSubItem href="external-auth"> |
114 |
| - External Authentication |
115 |
| - </SidebarNavSubItem> |
116 |
| - )} |
117 |
| - {/* Not exposing this yet since token exchange is not finished yet. |
118 |
| - <SidebarNavSubItem href="oauth2-provider/ap> |
119 |
| - OAuth2 Applications |
120 |
| - </SidebarNavSubItem>*/} |
121 |
| - {permissions.viewDeploymentValues && ( |
122 |
| - <SidebarNavSubItem href="network">Network</SidebarNavSubItem> |
123 |
| - )} |
124 |
| - {/* All users can view workspace regions. */} |
125 |
| - <SidebarNavSubItem href="workspace-proxies"> |
126 |
| - Workspace Proxies |
127 |
| - </SidebarNavSubItem> |
128 |
| - {permissions.viewDeploymentValues && ( |
129 |
| - <SidebarNavSubItem href="security">Security</SidebarNavSubItem> |
130 |
| - )} |
131 |
| - {permissions.viewDeploymentValues && ( |
132 |
| - <SidebarNavSubItem href="observability"> |
133 |
| - Observability |
134 |
| - </SidebarNavSubItem> |
135 |
| - )} |
136 |
| - {permissions.viewAllUsers && ( |
137 |
| - <SidebarNavSubItem href={USERS_LINK.slice(1)}> |
138 |
| - Users |
139 |
| - </SidebarNavSubItem> |
140 |
| - )} |
141 |
| - {permissions.viewAnyGroup && !organizationsEnabled && ( |
142 |
| - <SidebarNavSubItem href="groups">Groups</SidebarNavSubItem> |
143 |
| - )} |
144 |
| - {permissions.viewAnyAuditLog && ( |
145 |
| - <SidebarNavSubItem href={AUDIT_LINK.slice(1)}> |
146 |
| - Auditing |
147 |
| - </SidebarNavSubItem> |
148 |
| - )} |
149 |
| - </Stack> |
150 |
| - )} |
151 |
| - </div> |
152 |
| - ); |
153 |
| -}; |
154 |
| - |
155 |
| -function urlForSubpage(organizationName: string, subpage: string = ""): string { |
156 |
| - return `/organizations/${organizationName}/${subpage}`; |
157 |
| -} |
158 |
| - |
159 |
| -interface OrganizationSettingsNavigationProps { |
160 |
| - organization: Organization; |
161 |
| - active: boolean; |
162 |
| -} |
163 |
| - |
164 |
| -export const OrganizationSettingsNavigation: FC< |
165 |
| - OrganizationSettingsNavigationProps |
166 |
| -> = ({ organization, active }) => { |
167 |
| - // The menu items only show while the menu is expanded, so we can wait until |
168 |
| - // expanded to run the query. Downside is that we have to show a loader |
169 |
| - // until the query finishes...maybe we should prefetch permissions for all |
170 |
| - // the orgs instead? |
171 |
| - const permissionsQuery = useQuery( |
172 |
| - organizationPermissions(active ? organization.id : undefined), |
| 29 | + const activeOrgPermissionsQuery = useQuery( |
| 30 | + organizationPermissions(activeOrganization?.id), |
173 | 31 | );
|
174 | 32 |
|
175 | 33 | return (
|
176 |
| - <> |
177 |
| - <SidebarNavItem |
178 |
| - active={active} |
179 |
| - href={urlForSubpage(organization.name)} |
180 |
| - icon={ |
181 |
| - <UserAvatar |
182 |
| - key={organization.id} |
183 |
| - size="sm" |
184 |
| - username={organization.display_name} |
185 |
| - avatarURL={organization.icon} |
186 |
| - /> |
187 |
| - } |
188 |
| - > |
189 |
| - {organization.display_name} |
190 |
| - </SidebarNavItem> |
191 |
| - {active && !permissionsQuery.data && <Loader />} |
192 |
| - {active && permissionsQuery.data && ( |
193 |
| - <Stack spacing={0.5} css={{ marginBottom: 8, marginTop: 8 }}> |
194 |
| - {permissionsQuery.data.editOrganization && ( |
195 |
| - <SidebarNavSubItem end href={urlForSubpage(organization.name)}> |
196 |
| - Organization settings |
197 |
| - </SidebarNavSubItem> |
198 |
| - )} |
199 |
| - {permissionsQuery.data.viewMembers && ( |
200 |
| - <SidebarNavSubItem |
201 |
| - href={urlForSubpage(organization.name, "members")} |
202 |
| - > |
203 |
| - Members |
204 |
| - </SidebarNavSubItem> |
205 |
| - )} |
206 |
| - {permissionsQuery.data.viewGroups && ( |
207 |
| - <SidebarNavSubItem |
208 |
| - href={urlForSubpage(organization.name, "groups")} |
209 |
| - > |
210 |
| - Groups |
211 |
| - </SidebarNavSubItem> |
212 |
| - )} |
213 |
| - {/* For now redirect to the site-wide audit page with the organization |
214 |
| - pre-filled into the filter. Based on user feedback we might want |
215 |
| - to serve a copy of the audit page or even delete this link. */} |
216 |
| - {permissionsQuery.data.auditOrganization && ( |
217 |
| - <SidebarNavSubItem |
218 |
| - href={`/deployment${withFilter( |
219 |
| - AUDIT_LINK, |
220 |
| - `organization:${organization.name}`, |
221 |
| - )}`} |
222 |
| - > |
223 |
| - Auditing |
224 |
| - </SidebarNavSubItem> |
225 |
| - )} |
226 |
| - </Stack> |
227 |
| - )} |
228 |
| - </> |
| 34 | + <SidebarView |
| 35 | + activeOrganization={activeOrganization} |
| 36 | + activeOrgPermissions={activeOrgPermissionsQuery.data} |
| 37 | + organizations={organizations} |
| 38 | + permissions={permissions} |
| 39 | + /> |
229 | 40 | );
|
230 | 41 | };
|
231 |
| - |
232 |
| -interface SidebarNavItemProps { |
233 |
| - active?: boolean | "auto"; |
234 |
| - children?: ReactNode; |
235 |
| - icon?: ReactNode; |
236 |
| - href: string; |
237 |
| -} |
238 |
| - |
239 |
| -export const SidebarNavItem: FC<SidebarNavItemProps> = ({ |
240 |
| - active, |
241 |
| - children, |
242 |
| - href, |
243 |
| - icon, |
244 |
| -}) => { |
245 |
| - const link = useClassName(classNames.link, []); |
246 |
| - const activeLink = useClassName(classNames.activeLink, []); |
247 |
| - |
248 |
| - const content = ( |
249 |
| - <Stack alignItems="center" spacing={1.5} direction="row"> |
250 |
| - {icon} |
251 |
| - {children} |
252 |
| - </Stack> |
253 |
| - ); |
254 |
| - |
255 |
| - if (active === "auto") { |
256 |
| - return ( |
257 |
| - <NavLink |
258 |
| - to={href} |
259 |
| - className={({ isActive }) => cx([link, isActive && activeLink])} |
260 |
| - > |
261 |
| - {content} |
262 |
| - </NavLink> |
263 |
| - ); |
264 |
| - } |
265 |
| - |
266 |
| - return ( |
267 |
| - <Link to={href} className={cx([link, active && activeLink])}> |
268 |
| - {content} |
269 |
| - </Link> |
270 |
| - ); |
271 |
| -}; |
272 |
| - |
273 |
| -interface SidebarNavSubItemProps { |
274 |
| - children?: ReactNode; |
275 |
| - href: string; |
276 |
| - end?: boolean; |
277 |
| -} |
278 |
| - |
279 |
| -export const SidebarNavSubItem: FC<SidebarNavSubItemProps> = ({ |
280 |
| - children, |
281 |
| - href, |
282 |
| - end, |
283 |
| -}) => { |
284 |
| - const link = useClassName(classNames.subLink, []); |
285 |
| - const activeLink = useClassName(classNames.activeSubLink, []); |
286 |
| - |
287 |
| - return ( |
288 |
| - <NavLink |
289 |
| - end={end} |
290 |
| - to={href} |
291 |
| - className={({ isActive }) => cx([link, isActive && activeLink])} |
292 |
| - > |
293 |
| - {children} |
294 |
| - </NavLink> |
295 |
| - ); |
296 |
| -}; |
297 |
| - |
298 |
| -const styles = { |
299 |
| - sidebarHeader: { |
300 |
| - textTransform: "uppercase", |
301 |
| - letterSpacing: "0.15em", |
302 |
| - fontSize: 11, |
303 |
| - fontWeight: 500, |
304 |
| - paddingBottom: 4, |
305 |
| - }, |
306 |
| -} satisfies Record<string, Interpolation<Theme>>; |
307 |
| - |
308 |
| -const classNames = { |
309 |
| - link: (css, theme) => css` |
310 |
| - color: inherit; |
311 |
| - display: block; |
312 |
| - font-size: 14px; |
313 |
| - text-decoration: none; |
314 |
| - padding: 10px 12px 10px 16px; |
315 |
| - border-radius: 4px; |
316 |
| - transition: background-color 0.15s ease-in-out; |
317 |
| - position: relative; |
318 |
| -
|
319 |
| - &:hover { |
320 |
| - background-color: ${theme.palette.action.hover}; |
321 |
| - } |
322 |
| -
|
323 |
| - border-left: 3px solid transparent; |
324 |
| - `, |
325 |
| - |
326 |
| - activeLink: (css, theme) => css` |
327 |
| - border-left-color: ${theme.palette.primary.main}; |
328 |
| - border-top-left-radius: 0; |
329 |
| - border-bottom-left-radius: 0; |
330 |
| - `, |
331 |
| - |
332 |
| - subLink: (css, theme) => css` |
333 |
| - color: inherit; |
334 |
| - text-decoration: none; |
335 |
| -
|
336 |
| - display: block; |
337 |
| - font-size: 13px; |
338 |
| - margin-left: 44px; |
339 |
| - padding: 4px 12px; |
340 |
| - border-radius: 4px; |
341 |
| - transition: background-color 0.15s ease-in-out; |
342 |
| - margin-bottom: 1px; |
343 |
| - position: relative; |
344 |
| -
|
345 |
| - &:hover { |
346 |
| - background-color: ${theme.palette.action.hover}; |
347 |
| - } |
348 |
| - `, |
349 |
| - |
350 |
| - activeSubLink: (css) => css` |
351 |
| - font-weight: 600; |
352 |
| - `, |
353 |
| -} satisfies Record<string, ClassName>; |
354 |
| - |
355 |
| -const getOrganizationNameByDefault = (organizations: Organization[]) => |
356 |
| - organizations.find((org) => org.is_default)?.name; |
0 commit comments