mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: add organizations filter to audit table (#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.
This commit is contained in:
@ -13,8 +13,8 @@ We track the following resources:
|
|||||||
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||||
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
| AuditOAuthConvertState<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||||
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table> |
|
||||||
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>true</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
| AuditableOrganizationMember<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>roles</td><td>true</td></tr><tr><td>updated_at</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
|
||||||
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>true</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
| CustomRole<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table> |
|
||||||
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
|
||||||
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
|
| HealthSettings<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table> |
|
||||||
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
|
||||||
|
@ -53,7 +53,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
|||||||
&database.AuditableOrganizationMember{}: {
|
&database.AuditableOrganizationMember{}: {
|
||||||
"username": ActionTrack,
|
"username": ActionTrack,
|
||||||
"user_id": ActionTrack,
|
"user_id": ActionTrack,
|
||||||
"organization_id": ActionTrack,
|
"organization_id": ActionIgnore, // Never changes.
|
||||||
"created_at": ActionTrack,
|
"created_at": ActionTrack,
|
||||||
"updated_at": ActionTrack,
|
"updated_at": ActionTrack,
|
||||||
"roles": ActionTrack,
|
"roles": ActionTrack,
|
||||||
@ -64,7 +64,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
|||||||
"site_permissions": ActionTrack,
|
"site_permissions": ActionTrack,
|
||||||
"org_permissions": ActionTrack,
|
"org_permissions": ActionTrack,
|
||||||
"user_permissions": ActionTrack,
|
"user_permissions": ActionTrack,
|
||||||
"organization_id": ActionTrack,
|
"organization_id": ActionIgnore, // Never changes.
|
||||||
|
|
||||||
"id": ActionIgnore,
|
"id": ActionIgnore,
|
||||||
"created_at": ActionIgnore,
|
"created_at": ActionIgnore,
|
||||||
|
@ -32,6 +32,7 @@ export type SelectFilterProps = {
|
|||||||
onSelect: (option: SelectFilterOption | undefined) => void;
|
onSelect: (option: SelectFilterOption | undefined) => void;
|
||||||
// SelectFilterSearch element
|
// SelectFilterSearch element
|
||||||
selectFilterSearch?: ReactNode;
|
selectFilterSearch?: ReactNode;
|
||||||
|
width?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectFilter: FC<SelectFilterProps> = ({
|
export const SelectFilter: FC<SelectFilterProps> = ({
|
||||||
@ -42,6 +43,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
emptyText,
|
emptyText,
|
||||||
selectFilterSearch,
|
selectFilterSearch,
|
||||||
|
width = BASE_WIDTH,
|
||||||
}) => {
|
}) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@ -50,7 +52,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
|
|||||||
<SelectMenuTrigger>
|
<SelectMenuTrigger>
|
||||||
<SelectMenuButton
|
<SelectMenuButton
|
||||||
startIcon={selectedOption?.startIcon}
|
startIcon={selectedOption?.startIcon}
|
||||||
css={{ width: BASE_WIDTH }}
|
css={{ width }}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
{selectedOption?.label ?? placeholder}
|
{selectedOption?.label ?? placeholder}
|
||||||
@ -64,7 +66,7 @@ export const SelectFilter: FC<SelectFilterProps> = ({
|
|||||||
// wide as possible.
|
// wide as possible.
|
||||||
width: selectFilterSearch ? "100%" : undefined,
|
width: selectFilterSearch ? "100%" : undefined,
|
||||||
maxWidth: POPOVER_WIDTH,
|
maxWidth: POPOVER_WIDTH,
|
||||||
minWidth: BASE_WIDTH,
|
minWidth: width,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -97,9 +97,10 @@ export type UserFilterMenu = ReturnType<typeof useUserFilterMenu>;
|
|||||||
|
|
||||||
interface UserMenuProps {
|
interface UserMenuProps {
|
||||||
menu: UserFilterMenu;
|
menu: UserFilterMenu;
|
||||||
|
width?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
|
export const UserMenu: FC<UserMenuProps> = ({ menu, width }) => {
|
||||||
return (
|
return (
|
||||||
<SelectFilter
|
<SelectFilter
|
||||||
label="Select user"
|
label="Select user"
|
||||||
@ -116,6 +117,7 @@ export const UserMenu: FC<UserMenuProps> = ({ menu }) => {
|
|||||||
onChange={menu.setQuery}
|
onChange={menu.setQuery}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
width={width}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import capitalize from "lodash/capitalize";
|
import capitalize from "lodash/capitalize";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { API } from "api/api";
|
||||||
import { AuditActions, ResourceTypes } from "api/typesGenerated";
|
import { AuditActions, ResourceTypes } from "api/typesGenerated";
|
||||||
import {
|
import {
|
||||||
Filter,
|
Filter,
|
||||||
@ -13,9 +14,11 @@ import {
|
|||||||
} from "components/Filter/menu";
|
} from "components/Filter/menu";
|
||||||
import {
|
import {
|
||||||
SelectFilter,
|
SelectFilter,
|
||||||
|
SelectFilterSearch,
|
||||||
type SelectFilterOption,
|
type SelectFilterOption,
|
||||||
} from "components/Filter/SelectFilter";
|
} from "components/Filter/SelectFilter";
|
||||||
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
|
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
|
||||||
|
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
|
|
||||||
const PRESET_FILTERS = [
|
const PRESET_FILTERS = [
|
||||||
@ -42,10 +45,14 @@ interface AuditFilterProps {
|
|||||||
user: UserFilterMenu;
|
user: UserFilterMenu;
|
||||||
action: ActionFilterMenu;
|
action: ActionFilterMenu;
|
||||||
resourceType: ResourceTypeFilterMenu;
|
resourceType: ResourceTypeFilterMenu;
|
||||||
|
// The organization menu is only provided in a multi-org setup.
|
||||||
|
organization?: OrganizationsFilterMenu;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
||||||
|
// Use a smaller width if including the organization filter.
|
||||||
|
const width = menus.organization && 175;
|
||||||
return (
|
return (
|
||||||
<Filter
|
<Filter
|
||||||
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
|
learnMoreLink={docs("/admin/audit-logs#filtering-logs")}
|
||||||
@ -55,9 +62,12 @@ export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
|
|||||||
error={error}
|
error={error}
|
||||||
options={
|
options={
|
||||||
<>
|
<>
|
||||||
<ResourceTypeMenu {...menus.resourceType} />
|
<ResourceTypeMenu width={width} menu={menus.resourceType} />
|
||||||
<ActionMenu {...menus.action} />
|
<ActionMenu width={width} menu={menus.action} />
|
||||||
<UserMenu menu={menus.user} />
|
<UserMenu width={width} menu={menus.user} />
|
||||||
|
{menus.organization && (
|
||||||
|
<OrganizationsMenu width={width} menu={menus.organization} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
skeleton={
|
skeleton={
|
||||||
@ -92,7 +102,12 @@ export const useActionFilterMenu = ({
|
|||||||
|
|
||||||
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>;
|
export type ActionFilterMenu = ReturnType<typeof useActionFilterMenu>;
|
||||||
|
|
||||||
const ActionMenu = (menu: ActionFilterMenu) => {
|
interface ActionMenuProps {
|
||||||
|
menu: ActionFilterMenu;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionMenu: FC<ActionMenuProps> = ({ menu, width }) => {
|
||||||
return (
|
return (
|
||||||
<SelectFilter
|
<SelectFilter
|
||||||
label="Select an action"
|
label="Select an action"
|
||||||
@ -100,6 +115,7 @@ const ActionMenu = (menu: ActionFilterMenu) => {
|
|||||||
options={menu.searchOptions}
|
options={menu.searchOptions}
|
||||||
onSelect={menu.selectOption}
|
onSelect={menu.selectOption}
|
||||||
selectedOption={menu.selectedOption ?? undefined}
|
selectedOption={menu.selectedOption ?? undefined}
|
||||||
|
width={width}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -146,7 +162,12 @@ export type ResourceTypeFilterMenu = ReturnType<
|
|||||||
typeof useResourceTypeFilterMenu
|
typeof useResourceTypeFilterMenu
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
|
interface ResourceTypeMenuProps {
|
||||||
|
menu: ResourceTypeFilterMenu;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResourceTypeMenu: FC<ResourceTypeMenuProps> = ({ menu, width }) => {
|
||||||
return (
|
return (
|
||||||
<SelectFilter
|
<SelectFilter
|
||||||
label="Select a resource type"
|
label="Select a resource type"
|
||||||
@ -154,6 +175,88 @@ const ResourceTypeMenu = (menu: ResourceTypeFilterMenu) => {
|
|||||||
options={menu.searchOptions}
|
options={menu.searchOptions}
|
||||||
onSelect={menu.selectOption}
|
onSelect={menu.selectOption}
|
||||||
selectedOption={menu.selectedOption ?? undefined}
|
selectedOption={menu.selectedOption ?? undefined}
|
||||||
|
width={width}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrganizationsFilterMenu = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: Pick<UseFilterMenuOptions<SelectFilterOption>, "value" | "onChange">) => {
|
||||||
|
return useFilterMenu({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
id: "organizations",
|
||||||
|
getSelectedOption: async () => {
|
||||||
|
if (value) {
|
||||||
|
const organizations = await API.getOrganizations();
|
||||||
|
const organization = organizations.find((o) => o.name === value);
|
||||||
|
if (organization) {
|
||||||
|
return {
|
||||||
|
label: organization.display_name || organization.name,
|
||||||
|
value: organization.name,
|
||||||
|
startIcon: (
|
||||||
|
<UserAvatar
|
||||||
|
key={organization.id}
|
||||||
|
size="xs"
|
||||||
|
username={organization.display_name || organization.name}
|
||||||
|
avatarURL={organization.icon}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getOptions: async () => {
|
||||||
|
const organizationsRes = await API.getOrganizations();
|
||||||
|
return organizationsRes.map<SelectFilterOption>((organization) => ({
|
||||||
|
label: organization.display_name || organization.name,
|
||||||
|
value: organization.name,
|
||||||
|
startIcon: (
|
||||||
|
<UserAvatar
|
||||||
|
key={organization.id}
|
||||||
|
size="xs"
|
||||||
|
username={organization.display_name || organization.name}
|
||||||
|
avatarURL={organization.icon}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationsFilterMenu = ReturnType<
|
||||||
|
typeof useOrganizationsFilterMenu
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface OrganizationsMenuProps {
|
||||||
|
menu: OrganizationsFilterMenu;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OrganizationsMenu: FC<OrganizationsMenuProps> = ({
|
||||||
|
menu,
|
||||||
|
width,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<SelectFilter
|
||||||
|
label="Select an organization"
|
||||||
|
placeholder="All organizations"
|
||||||
|
emptyText="No organizations found"
|
||||||
|
options={menu.searchOptions}
|
||||||
|
onSelect={menu.selectOption}
|
||||||
|
selectedOption={menu.selectedOption ?? undefined}
|
||||||
|
selectFilterSearch={
|
||||||
|
<SelectFilterSearch
|
||||||
|
inputProps={{ "aria-label": "Search organization" }}
|
||||||
|
placeholder="Search organization..."
|
||||||
|
value={menu.query}
|
||||||
|
onChange={menu.setQuery}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
width={width}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
import type { CSSObject, Interpolation, Theme } from "@emotion/react";
|
||||||
import Collapse from "@mui/material/Collapse";
|
import Collapse from "@mui/material/Collapse";
|
||||||
|
import Link from "@mui/material/Link";
|
||||||
import TableCell from "@mui/material/TableCell";
|
import TableCell from "@mui/material/TableCell";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import userAgentParser from "ua-parser-js";
|
import userAgentParser from "ua-parser-js";
|
||||||
import type { AuditLog } from "api/typesGenerated";
|
import type { AuditLog } from "api/typesGenerated";
|
||||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
||||||
@ -33,11 +35,13 @@ export interface AuditLogRowProps {
|
|||||||
auditLog: AuditLog;
|
auditLog: AuditLog;
|
||||||
// Useful for Storybook
|
// Useful for Storybook
|
||||||
defaultIsDiffOpen?: boolean;
|
defaultIsDiffOpen?: boolean;
|
||||||
|
showOrgDetails: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuditLogRow: FC<AuditLogRowProps> = ({
|
export const AuditLogRow: FC<AuditLogRowProps> = ({
|
||||||
auditLog,
|
auditLog,
|
||||||
defaultIsDiffOpen = false,
|
defaultIsDiffOpen = false,
|
||||||
|
showOrgDetails,
|
||||||
}) => {
|
}) => {
|
||||||
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen);
|
const [isDiffOpen, setIsDiffOpen] = useState(defaultIsDiffOpen);
|
||||||
const diffs = Object.entries(auditLog.diff);
|
const diffs = Object.entries(auditLog.diff);
|
||||||
@ -132,6 +136,20 @@ export const AuditLogRow: FC<AuditLogRowProps> = ({
|
|||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showOrgDetails && auditLog.organization && (
|
||||||
|
<span css={styles.auditLogInfo}>
|
||||||
|
<>Org: </>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/organizations/${auditLog.organization.name}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{auditLog.organization.display_name ||
|
||||||
|
auditLog.organization.name}
|
||||||
|
</strong>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Pill
|
<Pill
|
||||||
|
@ -6,13 +6,19 @@ import { useFilter } from "components/Filter/filter";
|
|||||||
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||||
import { isNonInitialPage } from "components/PaginationWidget/utils";
|
import { isNonInitialPage } from "components/PaginationWidget/utils";
|
||||||
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
|
import { usePaginatedQuery } from "hooks/usePaginatedQuery";
|
||||||
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
|
import {
|
||||||
|
useActionFilterMenu,
|
||||||
|
useOrganizationsFilterMenu,
|
||||||
|
useResourceTypeFilterMenu,
|
||||||
|
} from "./AuditFilter";
|
||||||
import { AuditPageView } from "./AuditPageView";
|
import { AuditPageView } from "./AuditPageView";
|
||||||
|
|
||||||
const AuditPage: FC = () => {
|
const AuditPage: FC = () => {
|
||||||
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
|
const { audit_log: isAuditLogVisible } = useFeatureVisibility();
|
||||||
|
const { experiments } = useDashboard();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* There is an implicit link between auditsQuery and filter via the
|
* There is an implicit link between auditsQuery and filter via the
|
||||||
@ -55,6 +61,15 @@ const AuditPage: FC = () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const organizationsMenu = useOrganizationsFilterMenu({
|
||||||
|
value: filter.values.organization,
|
||||||
|
onChange: (option) =>
|
||||||
|
filter.update({
|
||||||
|
...filter.values,
|
||||||
|
organization: option?.value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@ -67,6 +82,7 @@ const AuditPage: FC = () => {
|
|||||||
isAuditLogVisible={isAuditLogVisible}
|
isAuditLogVisible={isAuditLogVisible}
|
||||||
auditsQuery={auditsQuery}
|
auditsQuery={auditsQuery}
|
||||||
error={auditsQuery.error}
|
error={auditsQuery.error}
|
||||||
|
showOrgDetails={experiments.includes("multi-organization")}
|
||||||
filterProps={{
|
filterProps={{
|
||||||
filter,
|
filter,
|
||||||
error: auditsQuery.error,
|
error: auditsQuery.error,
|
||||||
@ -74,6 +90,9 @@ const AuditPage: FC = () => {
|
|||||||
user: userMenu,
|
user: userMenu,
|
||||||
action: actionMenu,
|
action: actionMenu,
|
||||||
resourceType: resourceTypeMenu,
|
resourceType: resourceTypeMenu,
|
||||||
|
organization: experiments.includes("multi-organization")
|
||||||
|
? organizationsMenu
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -10,7 +10,12 @@ import {
|
|||||||
} from "components/PaginationWidget/PaginationContainer.mocks";
|
} from "components/PaginationWidget/PaginationContainer.mocks";
|
||||||
import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
|
import type { UsePaginatedQueryResult } from "hooks/usePaginatedQuery";
|
||||||
import { chromaticWithTablet } from "testHelpers/chromatic";
|
import { chromaticWithTablet } from "testHelpers/chromatic";
|
||||||
import { MockAuditLog, MockAuditLog2, MockUser } from "testHelpers/entities";
|
import {
|
||||||
|
MockAuditLog,
|
||||||
|
MockAuditLog2,
|
||||||
|
MockAuditLog3,
|
||||||
|
MockUser,
|
||||||
|
} from "testHelpers/entities";
|
||||||
import { AuditPageView } from "./AuditPageView";
|
import { AuditPageView } from "./AuditPageView";
|
||||||
|
|
||||||
type FilterProps = ComponentProps<typeof AuditPageView>["filterProps"];
|
type FilterProps = ComponentProps<typeof AuditPageView>["filterProps"];
|
||||||
@ -21,6 +26,7 @@ const defaultFilterProps = getDefaultFilterProps<FilterProps>({
|
|||||||
username: MockUser.username,
|
username: MockUser.username,
|
||||||
action: undefined,
|
action: undefined,
|
||||||
resource_type: undefined,
|
resource_type: undefined,
|
||||||
|
organization: undefined,
|
||||||
},
|
},
|
||||||
menus: {
|
menus: {
|
||||||
user: MockMenu,
|
user: MockMenu,
|
||||||
@ -33,9 +39,10 @@ const meta: Meta<typeof AuditPageView> = {
|
|||||||
title: "pages/AuditPage",
|
title: "pages/AuditPage",
|
||||||
component: AuditPageView,
|
component: AuditPageView,
|
||||||
args: {
|
args: {
|
||||||
auditLogs: [MockAuditLog, MockAuditLog2],
|
auditLogs: [MockAuditLog, MockAuditLog2, MockAuditLog3],
|
||||||
isAuditLogVisible: true,
|
isAuditLogVisible: true,
|
||||||
filterProps: defaultFilterProps,
|
filterProps: defaultFilterProps,
|
||||||
|
showOrgDetails: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -85,3 +92,18 @@ export const NotVisible: Story = {
|
|||||||
auditsQuery: mockInitialRenderResult,
|
auditsQuery: mockInitialRenderResult,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MultiOrg: Story = {
|
||||||
|
parameters: { chromatic: chromaticWithTablet },
|
||||||
|
args: {
|
||||||
|
showOrgDetails: true,
|
||||||
|
auditsQuery: mockSuccessResult,
|
||||||
|
filterProps: {
|
||||||
|
...defaultFilterProps,
|
||||||
|
menus: {
|
||||||
|
...defaultFilterProps.menus,
|
||||||
|
organization: MockMenu,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -38,6 +38,7 @@ export interface AuditPageViewProps {
|
|||||||
error?: unknown;
|
error?: unknown;
|
||||||
filterProps: ComponentProps<typeof AuditFilter>;
|
filterProps: ComponentProps<typeof AuditFilter>;
|
||||||
auditsQuery: PaginationResult;
|
auditsQuery: PaginationResult;
|
||||||
|
showOrgDetails: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuditPageView: FC<AuditPageViewProps> = ({
|
export const AuditPageView: FC<AuditPageViewProps> = ({
|
||||||
@ -47,6 +48,7 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
|||||||
error,
|
error,
|
||||||
filterProps,
|
filterProps,
|
||||||
auditsQuery: paginationResult,
|
auditsQuery: paginationResult,
|
||||||
|
showOrgDetails,
|
||||||
}) => {
|
}) => {
|
||||||
const isLoading =
|
const isLoading =
|
||||||
(auditLogs === undefined || paginationResult.totalRecords === undefined) &&
|
(auditLogs === undefined || paginationResult.totalRecords === undefined) &&
|
||||||
@ -117,7 +119,11 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
|
|||||||
items={auditLogs}
|
items={auditLogs}
|
||||||
getDate={(log) => new Date(log.time)}
|
getDate={(log) => new Date(log.time)}
|
||||||
row={(log) => (
|
row={(log) => (
|
||||||
<AuditLogRow key={log.id} auditLog={log} />
|
<AuditLogRow
|
||||||
|
key={log.id}
|
||||||
|
auditLog={log}
|
||||||
|
showOrgDetails={showOrgDetails}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -2211,6 +2211,9 @@ export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = {
|
|||||||
|
|
||||||
export const MockExperiments: TypesGen.Experiment[] = [];
|
export const MockExperiments: TypesGen.Experiment[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An audit log for MockOrganization.
|
||||||
|
*/
|
||||||
export const MockAuditLog: TypesGen.AuditLog = {
|
export const MockAuditLog: TypesGen.AuditLog = {
|
||||||
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||||
request_id: "53bded77-7b9d-4e82-8771-991a34d759f9",
|
request_id: "53bded77-7b9d-4e82-8771-991a34d759f9",
|
||||||
@ -2218,9 +2221,9 @@ export const MockAuditLog: TypesGen.AuditLog = {
|
|||||||
organization_id: MockOrganization.id,
|
organization_id: MockOrganization.id,
|
||||||
organization: {
|
organization: {
|
||||||
id: MockOrganization.id,
|
id: MockOrganization.id,
|
||||||
name: "mock name",
|
name: MockOrganization.name,
|
||||||
display_name: "mock display name",
|
display_name: MockOrganization.display_name,
|
||||||
icon: "/emojis/1f48f-1f3ff.png",
|
icon: MockOrganization.icon,
|
||||||
},
|
},
|
||||||
ip: "127.0.0.1",
|
ip: "127.0.0.1",
|
||||||
user_agent:
|
user_agent:
|
||||||
@ -2245,12 +2248,22 @@ export const MockAuditLog: TypesGen.AuditLog = {
|
|||||||
is_deleted: false,
|
is_deleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An audit log for MockOrganization2.
|
||||||
|
*/
|
||||||
export const MockAuditLog2: TypesGen.AuditLog = {
|
export const MockAuditLog2: TypesGen.AuditLog = {
|
||||||
...MockAuditLog,
|
...MockAuditLog,
|
||||||
id: "53bded77-7b9d-4e82-8771-991a34d759f9",
|
id: "53bded77-7b9d-4e82-8771-991a34d759f9",
|
||||||
action: "write",
|
action: "write",
|
||||||
time: "2022-05-20T16:45:57.122Z",
|
time: "2022-05-20T16:45:57.122Z",
|
||||||
description: "{user} updated workspace {target}",
|
description: "{user} updated workspace {target}",
|
||||||
|
organization_id: MockOrganization2.id,
|
||||||
|
organization: {
|
||||||
|
id: MockOrganization2.id,
|
||||||
|
name: MockOrganization2.name,
|
||||||
|
display_name: MockOrganization2.display_name,
|
||||||
|
icon: MockOrganization2.icon,
|
||||||
|
},
|
||||||
diff: {
|
diff: {
|
||||||
workspace_name: {
|
workspace_name: {
|
||||||
old: "old-workspace-name",
|
old: "old-workspace-name",
|
||||||
@ -2275,6 +2288,37 @@ export const MockAuditLog2: TypesGen.AuditLog = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An audit log without an organization.
|
||||||
|
*/
|
||||||
|
export const MockAuditLog3: TypesGen.AuditLog = {
|
||||||
|
id: "8efa9208-656a-422d-842d-b9dec0cf1bf3",
|
||||||
|
request_id: "57ee9510-8330-480d-9ffa-4024e5805465",
|
||||||
|
time: "2024-06-11T01:32:11.123Z",
|
||||||
|
organization_id: "00000000-0000-0000-000000000000",
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
user_agent:
|
||||||
|
'"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"',
|
||||||
|
resource_type: "template",
|
||||||
|
resource_id: "a624458c-1562-4689-a671-42c0b7d2d0c5",
|
||||||
|
resource_target: "docker",
|
||||||
|
resource_icon: "",
|
||||||
|
action: "write",
|
||||||
|
diff: {
|
||||||
|
display_name: {
|
||||||
|
old: "old display",
|
||||||
|
new: "new display",
|
||||||
|
secret: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status_code: 200,
|
||||||
|
additional_fields: {},
|
||||||
|
description: "{user} updated template {target}",
|
||||||
|
user: MockUser,
|
||||||
|
resource_link: "/templates/docker",
|
||||||
|
is_deleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const MockWorkspaceCreateAuditLogForDifferentOwner = {
|
export const MockWorkspaceCreateAuditLogForDifferentOwner = {
|
||||||
...MockAuditLog,
|
...MockAuditLog,
|
||||||
additional_fields: {
|
additional_fields: {
|
||||||
|
Reference in New Issue
Block a user