Skip to content

Commit e8b3db8

Browse files
authored
feat: add organizations filter to audit table (coder#13978)
* Ignore organization ID in member and role audit logs Since the organization will never change in any resources, and the org is already on the top-level of the response. * Add organization details and filter to audit table These only display if the multi-org experiment is enabled. This also includes a modification to customize the width of the filters since with four things get a bit squishy. * Add more audit mocks To test different org names and no org.
1 parent 4f01372 commit e8b3db8

File tree

10 files changed

+235
-19
lines changed

10 files changed

+235
-19
lines changed

docs/admin/audit-logs.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

enterprise/audit/table.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
5353
&database.AuditableOrganizationMember{}: {
5454
"username": ActionTrack,
5555
"user_id": ActionTrack,
56-
"organization_id": ActionTrack,
56+
"organization_id": ActionIgnore, // Never changes.
5757
"created_at": ActionTrack,
5858
"updated_at": ActionTrack,
5959
"roles": ActionTrack,
@@ -64,7 +64,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
6464
"site_permissions": ActionTrack,
6565
"org_permissions": ActionTrack,
6666
"user_permissions": ActionTrack,
67-
"organization_id": ActionTrack,
67+
"organization_id": ActionIgnore, // Never changes.
6868

6969
"id": ActionIgnore,
7070
"created_at": ActionIgnore,

site/src/components/Filter/SelectFilter.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type SelectFilterProps = {
3232
onSelect: (option: SelectFilterOption | undefined) => void;
3333
// SelectFilterSearch element
3434
selectFilterSearch?: ReactNode;
35+
width?: number;
3536
};
3637

3738
export const SelectFilter: FC<SelectFilterProps> = ({
@@ -42,6 +43,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
4243
placeholder,
4344
emptyText,
4445
selectFilterSearch,
46+
width = BASE_WIDTH,
4547
}) => {
4648
const [open, setOpen] = useState(false);
4749

@@ -50,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
5052
<SelectMenuTrigger>
5153
<SelectMenuButton
5254
startIcon={selectedOption?.startIcon}
53-
css={{ width: BASE_WIDTH }}
55+
css={{ width }}
5456
aria-label={label}
5557
>
5658
{selectedOption?.label ?? placeholder}
@@ -64,7 +66,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
6466
// wide as possible.
6567
width: selectFilterSearch ? "100%" : undefined,
6668
maxWidth: POPOVER_WIDTH,
67-
minWidth: BASE_WIDTH,
69+
minWidth: width,
6870
},
6971
}}
7072
>

site/src/components/Filter/UserFilter.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,10 @@ export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>;
9797

9898
interface UserMenuProps {
9999
menu: UserFilterMenu;
100+
width?: number;
100101
}
101102

102-
export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
103+
export const UserMenu: FC<UserMenuProps> = ({ menu, width }) => {
103104
return (
104105
<SelectFilter
105106
label="Select user"
@@ -116,6 +117,7 @@ export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
116117
onChange={menu.setQuery}
117118
/>
118119
}
120+
width={width}
119121
/>
120122
);
121123
};

site/src/pages/AuditPage/AuditFilter.tsx

Lines changed: 108 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import capitalize from "lodash/capitalize";
22
import type { FC } from "react";
3+
import { API } from "api/api";
34
import { AuditActions, ResourceTypes } from "api/typesGenerated";
45
import {
56
Filter,
@@ -13,9 +14,11 @@ import {
1314
} from "components/Filter/menu";
1415
import {
1516
SelectFilter,
17+
SelectFilterSearch,
1618
type SelectFilterOption,
1719
} from "components/Filter/SelectFilter";
1820
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
21+
import { UserAvatar } from "components/UserAvatar/UserAvatar";
1922
import { docs } from "utils/docs";
2023

2124
const PRESET_FILTERS = [
@@ -42,10 +45,14 @@ interface AuditFilterProps {
4245
user: UserFilterMenu;
4346
action: ActionFilterMenu;
4447
resourceType: ResourceTypeFilterMenu;
48+
// The organization menu is only provided in a multi-org setup.
49+
organization?: OrganizationsFilterMenu;
4550
};
4651
}
4752

4853
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
54+
// Use a smaller width if including the organization filter.
55+
const width = menus.organization && 175;
4956
return (
5057
<Filter
5158
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
@@ -55,9 +62,12 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
5562
error={error}
5663
options={
5764
<>
58-
<ResourceTypeMenu {...menus.resourceType} />
59-
<ActionMenu {...menus.action} />
60-
<UserMenu menu={menus.user} />
65+
<ResourceTypeMenu width={width} menu={menus.resourceType} />
66+
<ActionMenu width={width} menu={menus.action} />
67+
<UserMenu width={width} menu={menus.user} />
68+
{menus.organization && (
69+
<OrganizationsMenu width={width} menu={menus.organization} />
70+
)}
6171
</>
6272
}
6373
skeleton={
@@ -92,14 +102,20 @@ export const useActionFilterMenu = ({
92102

93103
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>;
94104

95-
const ActionMenu = (menu: ActionFilterMenu) => {
105+
interface ActionMenuProps {
106+
menu: ActionFilterMenu;
107+
width?: number;
108+
}
109+
110+
const ActionMenu: FC<ActionMenuProps> = ({ menu, width }) => {
96111
return (
97112
<SelectFilter
98113
label="Select an action"
99114
placeholder="All actions"
100115
options={menu.searchOptions}
101116
onSelect={menu.selectOption}
102117
selectedOption={menu.selectedOption ?? undefined}
118+
width={width}
103119
/>
104120
);
105121
};
@@ -146,14 +162,101 @@ export type ResourceTypeFilterMenu = ReturnType<
146162
typeof useResourceTypeFilterMenu
147163
>;
148164

149-
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
165+
interface ResourceTypeMenuProps {
166+
menu: ResourceTypeFilterMenu;
167+
width?: number;
168+
}
169+
170+
const ResourceTypeMenu: FC<ResourceTypeMenuProps> = ({ menu, width }) => {
150171
return (
151172
<SelectFilter
152173
label="Select a resource type"
153174
placeholder="All resource types"
154175
options={menu.searchOptions}
155176
onSelect={menu.selectOption}
156177
selectedOption={menu.selectedOption ?? undefined}
178+
width={width}
179+
/>
180+
);
181+
};
182+
183+
export const useOrganizationsFilterMenu = ({
184+
value,
185+
onChange,
186+
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
187+
return useFilterMenu({
188+
onChange,
189+
value,
190+
id: "organizations",
191+
getSelectedOption: async () => {
192+
if (value) {
193+
const organizations = await API.getOrganizations();
194+
const organization = organizations.find((o) => o.name === value);
195+
if (organization) {
196+
return {
197+
label: organization.display_name || organization.name,
198+
value: organization.name,
199+
startIcon: (
200+
<UserAvatar
201+
key={organization.id}
202+
size="xs"
203+
username={organization.display_name || organization.name}
204+
avatarURL={organization.icon}
205+
/>
206+
),
207+
};
208+
}
209+
}
210+
return null;
211+
},
212+
getOptions: async () => {
213+
const organizationsRes = await API.getOrganizations();
214+
return organizationsRes.map<SelectFilterOption>((organization) => ({
215+
label: organization.display_name || organization.name,
216+
value: organization.name,
217+
startIcon: (
218+
<UserAvatar
219+
key={organization.id}
220+
size="xs"
221+
username={organization.display_name || organization.name}
222+
avatarURL={organization.icon}
223+
/>
224+
),
225+
}));
226+
},
227+
});
228+
};
229+
230+
export type OrganizationsFilterMenu = ReturnType<
231+
typeof useOrganizationsFilterMenu
232+
>;
233+
234+
interface OrganizationsMenuProps {
235+
menu: OrganizationsFilterMenu;
236+
width?: number;
237+
}
238+
239+
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
240+
menu,
241+
width,
242+
}) => {
243+
return (
244+
<SelectFilter
245+
label="Select an organization"
246+
placeholder="All organizations"
247+
emptyText="No organizations found"
248+
options={menu.searchOptions}
249+
onSelect={menu.selectOption}
250+
selectedOption={menu.selectedOption ?? undefined}
251+
selectFilterSearch={
252+
<SelectFilterSearch
253+
inputProps={{ "aria-label": "Search organization" }}
254+
placeholder="Search organization..."
255+
value={menu.query}
256+
onChange={menu.setQuery}
257+
/>
258+
}
259+
width={width}
157260
/>
158261
);
159262
};

site/src/pages/AuditPage/AuditLogRow/AuditLogRow.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
22
import Collapse from "@mui/material/Collapse";
3+
import Link from "@mui/material/Link";
34
import TableCell from "@mui/material/TableCell";
45
import { type FC, useState } from "react";
6+
import { Link as RouterLink } from "react-router-dom";
57
import userAgentParser from "ua-parser-js";
68
import type { AuditLog } from "api/typesGenerated";
79
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
@@ -33,11 +35,13 @@ export interface AuditLogRowProps {
3335
auditLog: AuditLog;
3436
// Useful for Storybook
3537
defaultIsDiffOpen?: boolean;
38+
showOrgDetails: boolean;
3639
}
3740

3841
export const AuditLogRow: FC<AuditLogRowProps> = ({
3942
auditLog,
4043
defaultIsDiffOpen = false,
44+
showOrgDetails,
4145
}) => {
4246
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen);
4347
const diffs = Object.entries(auditLog.diff);
@@ -132,6 +136,20 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
132136
</strong>
133137
</span>
134138
)}
139+
{showOrgDetails && auditLog.organization && (
140+
<span css={styles.auditLogInfo}>
141+
<>Org: </>
142+
<Link
143+
component={RouterLink}
144+
to={`/organizations/${auditLog.organization.name}`}
145+
>
146+
<strong>
147+
{auditLog.organization.display_name ||
148+
auditLog.organization.name}
149+
</strong>
150+
</Link>
151+
</span>
152+
)}
135153
</Stack>
136154

137155
<Pill

site/src/pages/AuditPage/AuditPage.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@ import { useFilter } from "components/Filter/filter";
66
import { useUserFilterMenu } from "components/Filter/UserFilter";
77
import { isNonInitialPage } from "components/PaginationWidget/utils";
88
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
9+
import { useDashboard } from "modules/dashboard/useDashboard";
910
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
1011
import { pageTitle } from "utils/page";
11-
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
12+
import {
13+
useActionFilterMenu,
14+
useOrganizationsFilterMenu,
15+
useResourceTypeFilterMenu,
16+
} from "./AuditFilter";
1217
import { AuditPageView } from "./AuditPageView";
1318

1419
const AuditPage: FC = () => {
1520
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
21+
const { experiments } = useDashboard();
1622

1723
/**
1824
* There is an implicit link between auditsQuery and filter via the
@@ -55,6 +61,15 @@ const AuditPage: FC = () => {
5561
}),
5662
});
5763

64+
const organizationsMenu = useOrganizationsFilterMenu({
65+
value: filter.values.organization,
66+
onChange: (option) =>
67+
filter.update({
68+
...filter.values,
69+
organization: option?.value,
70+
}),
71+
});
72+
5873
return (
5974
<>
6075
<Helmet>
@@ -67,13 +82,17 @@ const AuditPage: FC = () => {
6782
isAuditLogVisible={isAuditLogVisible}
6883
auditsQuery={auditsQuery}
6984
error={auditsQuery.error}
85+
showOrgDetails={experiments.includes("multi-organization")}
7086
filterProps={{
7187
filter,
7288
error: auditsQuery.error,
7389
menus: {
7490
user: userMenu,
7591
action: actionMenu,
7692
resourceType: resourceTypeMenu,
93+
organization: experiments.includes("multi-organization")
94+
? organizationsMenu
95+
: undefined,
7796
},
7897
}}
7998
/>

site/src/pages/AuditPage/AuditPageView.stories.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import {
1010
} from "components/PaginationWidget/PaginationContainer.mocks";
1111
import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
1212
import { chromaticWithTablet } from "testHelpers/chromatic";
13-
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities";
13+
import {
14+
MockAuditLog,
15+
MockAuditLog2,
16+
MockAuditLog3,
17+
MockUser,
18+
} from "testHelpers/entities";
1419
import { AuditPageView } from "./AuditPageView";
1520

1621
type FilterProps = ComponentProps<typeof AuditPageView>["filterProps"];
@@ -21,6 +26,7 @@ const defaultFilterProps = getDefaultFilterProps<FilterProps>({
2126
username: MockUser.username,
2227
action: undefined,
2328
resource_type: undefined,
29+
organization: undefined,
2430
},
2531
menus: {
2632
user: MockMenu,
@@ -33,9 +39,10 @@ const meta: Meta<typeof AuditPageView> = {
3339
title: "pages/AuditPage",
3440
component: AuditPageView,
3541
args: {
36-
auditLogs: [MockAuditLog, MockAuditLog2],
42+
auditLogs: [MockAuditLog, MockAuditLog2, MockAuditLog3],
3743
isAuditLogVisible: true,
3844
filterProps: defaultFilterProps,
45+
showOrgDetails: false,
3946
},
4047
};
4148

@@ -85,3 +92,18 @@ export const NotVisible: Story = {
8592
auditsQuery: mockInitialRenderResult,
8693
},
8794
};
95+
96+
export const MultiOrg: Story = {
97+
parameters: { chromatic: chromaticWithTablet },
98+
args: {
99+
showOrgDetails: true,
100+
auditsQuery: mockSuccessResult,
101+
filterProps: {
102+
...defaultFilterProps,
103+
menus: {
104+
...defaultFilterProps.menus,
105+
organization: MockMenu,
106+
},
107+
},
108+
},
109+
};

0 commit comments

Comments
 (0)