1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: display sign on log (#3193)

## About the changes

https://linear.app/unleash/issue/2-719/display-basic-sign-on-log-in-frontend
Adds a basic, naïve way of displaying sign-on history. 
We can be OK with merging this for now if we want, given that it's
behind a feature flag, and then iterate over it and implement the
features and improvements we need in the meantime.


![image](https://user-images.githubusercontent.com/14320932/221217564-90868ce8-6608-4f30-b2e4-88f72f1e4ac6.png)

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#2951
This commit is contained in:
Nuno Góis 2023-02-27 08:07:22 +00:00 committed by GitHub
parent e7ef06ff9d
commit a43542b0d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 462 additions and 2 deletions

View File

@ -1,7 +1,7 @@
import { Tooltip, Typography } from '@mui/material';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { VFC } from 'react';
import { formatDateYMD } from 'utils/formatDate';
import { formatDateYMD, formatDateYMDHMS } from 'utils/formatDate';
import { TextCell } from '../TextCell/TextCell';
import TimeAgo from 'react-timeago';
@ -10,6 +10,7 @@ interface ITimeAgoCellProps {
live?: boolean;
emptyText?: string;
title?: (date: string) => string;
timestamp?: boolean;
}
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
@ -17,12 +18,15 @@ export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
live = false,
emptyText,
title,
timestamp,
}) => {
const { locationSettings } = useLocationSettings();
if (!value) return <TextCell>{emptyText}</TextCell>;
const date = formatDateYMD(value, locationSettings.locale);
const date = timestamp
? formatDateYMDHMS(value, locationSettings.locale)
: formatDateYMD(value, locationSettings.locale);
return (
<TextCell>

View File

@ -347,6 +347,15 @@ exports[`returns all baseRoutes 1`] = `
"title": "Event log",
"type": "protected",
},
{
"component": [Function],
"menu": {
"adminSettings": true,
},
"path": "/admin/sign-on-log",
"title": "Sign on log",
"type": "protected",
},
{
"component": [Function],
"menu": {},

View File

@ -43,6 +43,7 @@ import { LazyFeatureView } from 'component/feature/FeatureView/LazyFeatureView';
import { LazyAdmin } from 'component/admin/LazyAdmin';
import { LazyProject } from 'component/project/Project/LazyProject';
import { AdminRedirect } from 'component/admin/AdminRedirect';
import { SignOnLog } from 'component/signOnLog/SignOnLog';
export const routes: IRoute[] = [
// Splash
@ -355,6 +356,14 @@ export const routes: IRoute[] = [
menu: { adminSettings: true },
},
{
path: '/admin/sign-on-log',
title: 'Sign on log',
component: SignOnLog,
type: 'protected',
menu: { adminSettings: true },
},
// Archive
{
path: '/archive',
@ -438,6 +447,12 @@ export const adminMenuRoutes: INavigationMenuItem[] = [
title: 'Event log',
menu: { adminSettings: true },
},
{
path: '/admin/sign-on-log',
title: 'Sign-on log',
menu: { adminSettings: true },
flag: 'loginEventLog',
},
{
path: '/admin/users',
title: 'Users',

View File

@ -0,0 +1,20 @@
import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
import { SignOnLogTable } from './SignOnLogTable/SignOnLogTable';
export const SignOnLog = () => {
const { hasAccess } = useContext(AccessContext);
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<SignOnLogTable />}
elseShow={<AdminAlert />}
/>
</div>
);
};

View File

@ -0,0 +1,32 @@
import { Delete } from '@mui/icons-material';
import { Box, styled } from '@mui/material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
const StyledBox = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
}));
interface ISignOnLogActionsCellProps {
onDelete: (event: React.SyntheticEvent) => void;
}
export const SignOnLogActionsCell = ({
onDelete,
}: ISignOnLogActionsCellProps) => {
return (
<StyledBox>
<PermissionIconButton
data-loading
onClick={onDelete}
permission={ADMIN}
tooltipProps={{
title: 'Remove event',
}}
>
<Delete />
</PermissionIconButton>
</StyledBox>
);
};

View File

@ -0,0 +1,29 @@
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { ISignOnEvent } from 'interfaces/signOnEvent';
interface IServiceAccountDeleteDialogProps {
event?: ISignOnEvent;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: (event: ISignOnEvent) => void;
}
export const SignOnLogDeleteDialog = ({
event,
open,
setOpen,
onConfirm,
}: IServiceAccountDeleteDialogProps) => (
<Dialogue
title="Delete event?"
open={open}
primaryButtonText="Delete event"
secondaryButtonText="Cancel"
onClick={() => onConfirm(event!)}
onClose={() => {
setOpen(false);
}}
>
You are about to delete event: <strong>#{event?.id}</strong>
</Dialogue>
);

View File

@ -0,0 +1,48 @@
import { VFC } from 'react';
import { Box, styled } from '@mui/material';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { ISignOnEvent } from 'interfaces/signOnEvent';
import { Badge } from 'component/common/Badge/Badge';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
const StyledBox = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
}));
interface ISignOnLogSuccessfulCellProps {
row: {
original: ISignOnEvent;
};
value: boolean;
}
export const SignOnLogSuccessfulCell: VFC<ISignOnLogSuccessfulCellProps> = ({
row,
value,
}) => {
const { searchQuery } = useSearchHighlightContext();
if (value)
return (
<StyledBox>
<Badge color="success">True</Badge>
</StyledBox>
);
return (
<StyledBox>
<HtmlTooltip
arrow
title={
<Highlighter search={searchQuery}>
{row.original.failure_reason}
</Highlighter>
}
>
<Badge color="error">False</Badge>
</HtmlTooltip>
</StyledBox>
);
};

View File

@ -0,0 +1,234 @@
import { useMemo, useState } from 'react';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { useMediaQuery } from '@mui/material';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { useSearch } from 'hooks/useSearch';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { useSignOnLog } from 'hooks/api/getters/useSignOnLog/useSignOnLog';
import { SignOnLogSuccessfulCell } from './SignOnLogSuccessfulCell/SignOnLogSuccessfulCell';
import { ISignOnEvent } from 'interfaces/signOnEvent';
import { SignOnLogActionsCell } from './SignOnLogActionsCell/SignOnLogActionsCell';
import { SignOnLogDeleteDialog } from './SignOnLogDeleteDialog/SignOnLogDeleteDialog';
import { useSignOnLogApi } from 'hooks/api/actions/useSignOnLogApi/useSignOnLogApi';
export const SignOnLogTable = () => {
const { setToastData, setToastApiError } = useToast();
const { events, loading, refetch } = useSignOnLog();
const { removeEvent } = useSignOnLogApi();
const [searchValue, setSearchValue] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<ISignOnEvent>();
const onDeleteConfirm = async (event: ISignOnEvent) => {
try {
await removeEvent(event.id);
setToastData({
title: `Event has been deleted`,
type: 'success',
});
refetch();
setDeleteOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const columns = useMemo(
() => [
{
Header: 'Created',
accessor: 'created_at',
Cell: ({ value }: { value: Date }) => (
<TimeAgoCell value={value} timestamp />
),
sortType: 'date',
maxWidth: 150,
},
{
Header: 'Username',
accessor: 'username',
minWidth: 100,
Cell: HighlightCell,
searchable: true,
},
{
Header: 'Authentication',
accessor: (event: ISignOnEvent) =>
event.auth_type === 'simple'
? 'Password'
: event.auth_type.toUpperCase(),
width: 150,
maxWidth: 150,
Cell: HighlightCell,
searchable: true,
},
{
Header: 'IP address',
accessor: 'ip',
Cell: HighlightCell,
searchable: true,
},
{
Header: 'Successful',
accessor: 'successful',
align: 'center',
Cell: SignOnLogSuccessfulCell,
filterName: 'successful',
filterParsing: (value: boolean) => value.toString(),
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: event } }: any) => (
<SignOnLogActionsCell
onDelete={() => {
setSelectedEvent(event);
setDeleteOpen(true);
}}
/>
),
width: 150,
disableSortBy: true,
},
// Always hidden -- for search
{
accessor: 'failure_reason',
Header: 'Failure Reason',
searchable: true,
},
],
[]
);
const [initialState] = useState({
sortBy: [{ id: 'created_at' }],
hiddenColumns: ['failure_reason'],
});
const { data, getSearchText, getSearchContext } = useSearch(
columns,
searchValue,
events
);
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
columns: columns as any,
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy,
useFlexLayout
);
useConditionallyHiddenColumns(
[
{
condition: isExtraSmallScreen,
columns: ['role', 'seenAt'],
},
{
condition: isSmallScreen,
columns: ['imageUrl', 'tokens', 'createdAt'],
},
],
setHiddenColumns,
columns
);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Sign-on log (${rows.length})`}
actions={
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
}
/>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No sign-on events found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No sign-on events available.
</TablePlaceholder>
}
/>
}
/>
<SignOnLogDeleteDialog
event={selectedEvent}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</PageContent>
);
};

View File

@ -0,0 +1,24 @@
import useAPI from '../useApi/useApi';
export const useSignOnLogApi = () => {
const { loading, makeRequest, createRequest, errors } = useAPI({
propagateErrors: true,
});
const removeEvent = async (eventId: number) => {
const requestId = 'removeEvent';
const req = createRequest(
`api/admin/login-event/${eventId}`,
{ method: 'DELETE' },
requestId
);
await makeRequest(req.caller, req.id);
};
return {
removeEvent,
errors,
loading,
};
};

View File

@ -0,0 +1,35 @@
import { ISignOnEvent } from 'interfaces/signOnEvent';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
export const useSignOnLog = () => {
const { uiConfig, isEnterprise } = useUiConfig();
const { loginEventLog } = uiConfig.flags;
const { data, error, mutate } = useConditionalSWR(
loginEventLog && isEnterprise(),
[],
formatApiPath(`api/admin/login-event`),
fetcher
);
return useMemo(
() => ({
events: (data ?? []) as ISignOnEvent[],
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate]
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Sign-On Log'))
.then(res => res.json());
};

View File

@ -0,0 +1,9 @@
export interface ISignOnEvent {
id: number;
username: string;
auth_type: string;
created_at: Date;
successful: boolean;
ip?: string;
failure_reason?: string;
}

View File

@ -48,6 +48,7 @@ export interface IFlags {
showProjectApiAccess?: boolean;
proPlanAutoCharge?: boolean;
notifications?: boolean;
loginEventLog?: boolean;
}
export interface IVersionInfo {