mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-14 01:16:17 +02:00
feat: new profile page and PATs front-end (#2109)
* feat: new user dropdown and profile page * fix: add popup boxShadow to dark-theme * fix: update routes snap * refactor: move some tab specific logic into tabs component * add useProfile hook example * fix profile tab header (no name) * fix: hide user popup when clicking profile link * - add PATs to profile; - add route logic to profile; - refactor TimeAgoCell title; - misc fixes and refactoring; * add profile info to profile tab * simplify req paths * add PAT flag to the front-end * fix: some UI adjustments * change user popup buttons to links * fix profile on front-end, add role description * update delete PAT text * address some PR comments * address PR comments * some more UI fixes and refactoring * move password request to API hook
This commit is contained in:
parent
8099acd216
commit
ddcfe132e4
@ -115,7 +115,11 @@ export const Group: VFC = () => {
|
|||||||
Header: 'Last login',
|
Header: 'Last login',
|
||||||
accessor: (row: IGroupUser) => row.seenAt || '',
|
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||||
Cell: ({ row: { original: user } }: any) => (
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
<TimeAgoCell
|
||||||
|
value={user.seenAt}
|
||||||
|
emptyText="Never"
|
||||||
|
title={date => `Last login: ${date}`}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
maxWidth: 150,
|
maxWidth: 150,
|
||||||
|
@ -152,7 +152,11 @@ const UsersList = () => {
|
|||||||
Header: 'Last login',
|
Header: 'Last login',
|
||||||
accessor: (row: any) => row.seenAt || '',
|
accessor: (row: any) => row.seenAt || '',
|
||||||
Cell: ({ row: { original: user } }: any) => (
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
<TimeAgoCell
|
||||||
|
value={user.seenAt}
|
||||||
|
emptyText="Never"
|
||||||
|
title={date => `Last login: ${date}`}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
disableGlobalFilter: true,
|
disableGlobalFilter: true,
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
|
@ -14,6 +14,7 @@ type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
|
|||||||
interface IBadgeProps {
|
interface IBadgeProps {
|
||||||
color?: Color;
|
color?: Color;
|
||||||
icon?: ReactElement;
|
icon?: ReactElement;
|
||||||
|
iconRight?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
sx?: SxProps<Theme>;
|
sx?: SxProps<Theme>;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@ -23,6 +24,7 @@ interface IBadgeProps {
|
|||||||
|
|
||||||
interface IBadgeIconProps {
|
interface IBadgeIconProps {
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
iconRight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledBadge = styled('div')<IBadgeProps>(
|
const StyledBadge = styled('div')<IBadgeProps>(
|
||||||
@ -41,18 +43,35 @@ const StyledBadge = styled('div')<IBadgeProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
|
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
|
||||||
({ theme, color = 'neutral' }) => ({
|
({ theme, color = 'neutral', iconRight = false }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
color: theme.palette[color].main,
|
color: theme.palette[color].main,
|
||||||
marginRight: theme.spacing(0.5),
|
margin: iconRight
|
||||||
|
? theme.spacing(0, 0, 0, 0.5)
|
||||||
|
: theme.spacing(0, 0.5, 0, 0),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BadgeIcon = (color: Color, icon: ReactElement, iconRight = false) => (
|
||||||
|
<StyledBadgeIcon color={color} iconRight={iconRight}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(icon?.props.sx)}
|
||||||
|
show={icon}
|
||||||
|
elseShow={() =>
|
||||||
|
cloneElement(icon!, {
|
||||||
|
sx: { fontSize: '16px' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledBadgeIcon>
|
||||||
|
);
|
||||||
|
|
||||||
export const Badge: FC<IBadgeProps> = forwardRef(
|
export const Badge: FC<IBadgeProps> = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
color = 'neutral',
|
color = 'neutral',
|
||||||
icon,
|
icon,
|
||||||
|
iconRight,
|
||||||
className,
|
className,
|
||||||
sx,
|
sx,
|
||||||
children,
|
children,
|
||||||
@ -69,22 +88,14 @@ export const Badge: FC<IBadgeProps> = forwardRef(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(icon)}
|
condition={Boolean(icon) && !Boolean(iconRight)}
|
||||||
show={
|
show={BadgeIcon(color, icon!)}
|
||||||
<StyledBadgeIcon color={color}>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(icon?.props.sx)}
|
|
||||||
show={icon}
|
|
||||||
elseShow={() =>
|
|
||||||
cloneElement(icon!, {
|
|
||||||
sx: { fontSize: '16px' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</StyledBadgeIcon>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(icon) && Boolean(iconRight)}
|
||||||
|
show={BadgeIcon(color, icon!, true)}
|
||||||
|
/>
|
||||||
</StyledBadge>
|
</StyledBadge>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -26,7 +26,8 @@ const BreadcrumbNav = () => {
|
|||||||
item !== 'features' &&
|
item !== 'features' &&
|
||||||
item !== 'features2' &&
|
item !== 'features2' &&
|
||||||
item !== 'create-toggle' &&
|
item !== 'create-toggle' &&
|
||||||
item !== 'settings'
|
item !== 'settings' &&
|
||||||
|
item !== 'profile'
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -9,26 +9,24 @@ interface ITimeAgoCellProps {
|
|||||||
value?: string | number | Date;
|
value?: string | number | Date;
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
title?: (date: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
|
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
|
||||||
value,
|
value,
|
||||||
live = false,
|
live = false,
|
||||||
emptyText,
|
emptyText,
|
||||||
|
title,
|
||||||
}) => {
|
}) => {
|
||||||
const { locationSettings } = useLocationSettings();
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
if (!value) return <TextCell>{emptyText}</TextCell>;
|
if (!value) return <TextCell>{emptyText}</TextCell>;
|
||||||
|
|
||||||
|
const date = formatDateYMD(value, locationSettings.locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextCell>
|
<TextCell>
|
||||||
<Tooltip
|
<Tooltip title={title?.(date) ?? date} arrow>
|
||||||
title={`Last login: ${formatDateYMD(
|
|
||||||
value,
|
|
||||||
locationSettings.locale
|
|
||||||
)}`}
|
|
||||||
arrow
|
|
||||||
>
|
|
||||||
<Typography noWrap variant="body2" data-loading>
|
<Typography noWrap variant="body2" data-loading>
|
||||||
<TimeAgo date={new Date(value)} live={live} title={''} />
|
<TimeAgo date={new Date(value)} live={live} title={''} />
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
|
||||||
|
const StyledTab = styled('button')<{ selected: boolean }>(
|
||||||
|
({ theme, selected }) => ({
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 0,
|
||||||
|
backgroundColor: selected
|
||||||
|
? theme.palette.background.paper
|
||||||
|
: 'transparent',
|
||||||
|
borderLeft: `${theme.spacing(1)} solid ${
|
||||||
|
selected ? theme.palette.primary.main : 'transparent'
|
||||||
|
}`,
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
padding: theme.spacing(2, 4),
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.bodySize,
|
||||||
|
fontWeight: selected ? theme.fontWeight.bold : theme.fontWeight.medium,
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'background-color 0.2s ease',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.palette.neutral.light,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IVerticalTabProps {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalTab = ({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
}: IVerticalTabProps) => (
|
||||||
|
<StyledTab selected={Boolean(selected)} onClick={onClick}>
|
||||||
|
{label}
|
||||||
|
</StyledTab>
|
||||||
|
);
|
64
frontend/src/component/common/VerticalTabs/VerticalTabs.tsx
Normal file
64
frontend/src/component/common/VerticalTabs/VerticalTabs.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { VerticalTab } from './VerticalTab/VerticalTab';
|
||||||
|
|
||||||
|
const StyledTabPage = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing(3),
|
||||||
|
[theme.breakpoints.down('md')]: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTabPageContent = styled('div')(() => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTabs = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
width: theme.spacing(30),
|
||||||
|
flexShrink: 0,
|
||||||
|
[theme.breakpoints.down('md')]: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface ITab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path?: string;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IVerticalTabsProps {
|
||||||
|
tabs: ITab[];
|
||||||
|
value: string;
|
||||||
|
onChange: (tab: ITab) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalTabs = ({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: IVerticalTabsProps) => (
|
||||||
|
<StyledTabPage>
|
||||||
|
<StyledTabs>
|
||||||
|
{tabs
|
||||||
|
.filter(tab => !tab.hidden)
|
||||||
|
.map(tab => (
|
||||||
|
<VerticalTab
|
||||||
|
key={tab.id}
|
||||||
|
label={tab.label}
|
||||||
|
selected={tab.id === value}
|
||||||
|
onClick={() => onChange(tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</StyledTabs>
|
||||||
|
<StyledTabPageContent>{children}</StyledTabPageContent>
|
||||||
|
</StyledTabPage>
|
||||||
|
);
|
@ -490,5 +490,12 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Admin",
|
"title": "Admin",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"menu": {},
|
||||||
|
"path": "/profile/*",
|
||||||
|
"title": "Profile",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
@ -58,6 +58,7 @@ import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
|||||||
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
|
||||||
import { CorsAdmin } from 'component/admin/cors';
|
import { CorsAdmin } from 'component/admin/cors';
|
||||||
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||||
|
import { Profile } from 'component/user/Profile/Profile';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -529,6 +530,13 @@ export const routes: IRoute[] = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: {},
|
menu: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/*',
|
||||||
|
title: 'Profile',
|
||||||
|
component: Profile,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
},
|
||||||
|
|
||||||
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
|
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
|
||||||
{
|
{
|
||||||
|
@ -89,7 +89,11 @@ const columns = [
|
|||||||
Header: 'Last login',
|
Header: 'Last login',
|
||||||
accessor: (row: IGroupUser) => row.seenAt || '',
|
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||||
Cell: ({ row: { original: user } }: any) => (
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
<TimeAgoCell
|
||||||
|
value={user.seenAt}
|
||||||
|
emptyText="Never"
|
||||||
|
title={date => `Last login: ${date}`}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
maxWidth: 150,
|
maxWidth: 150,
|
||||||
|
@ -1,41 +1,30 @@
|
|||||||
import React, { SyntheticEvent, useState } from 'react';
|
import { Button, styled } from '@mui/material';
|
||||||
import { Button, Typography } from '@mui/material';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import classnames from 'classnames';
|
import PasswordField from 'component/common/PasswordField/PasswordField';
|
||||||
import { useStyles } from './EditProfile.styles';
|
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
|
||||||
import PasswordChecker, {
|
import PasswordChecker, {
|
||||||
PASSWORD_FORMAT_MESSAGE,
|
PASSWORD_FORMAT_MESSAGE,
|
||||||
} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
|
} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
|
||||||
import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
|
import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
|
||||||
import useLoading from 'hooks/useLoading';
|
import { usePasswordApi } from 'hooks/api/actions/usePasswordApi/usePasswordApi';
|
||||||
import {
|
import useToast from 'hooks/useToast';
|
||||||
BAD_REQUEST,
|
import { SyntheticEvent, useState } from 'react';
|
||||||
NOT_FOUND,
|
|
||||||
OK,
|
|
||||||
UNAUTHORIZED,
|
|
||||||
} from 'constants/statusCodes';
|
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
|
||||||
import PasswordField from 'component/common/PasswordField/PasswordField';
|
|
||||||
import { headers } from 'utils/apiUtils';
|
|
||||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
interface IEditProfileProps {
|
const StyledForm = styled('form')(({ theme }) => ({
|
||||||
setEditingProfile: React.Dispatch<React.SetStateAction<boolean>>;
|
display: 'flex',
|
||||||
setUpdatedPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
flexDirection: 'column',
|
||||||
}
|
gap: theme.spacing(2),
|
||||||
|
maxWidth: theme.spacing(44),
|
||||||
|
}));
|
||||||
|
|
||||||
const EditProfile = ({
|
export const PasswordTab = () => {
|
||||||
setEditingProfile,
|
|
||||||
setUpdatedPassword,
|
|
||||||
}: IEditProfileProps) => {
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
const [validPassword, setValidPassword] = useState(false);
|
const [validPassword, setValidPassword] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const ref = useLoading(loading);
|
const { changePassword } = usePasswordApi();
|
||||||
|
|
||||||
const submit = async (e: SyntheticEvent) => {
|
const submit = async (e: SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -48,54 +37,27 @@ const EditProfile = ({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
try {
|
try {
|
||||||
const path = formatApiPath('api/admin/user/change-password');
|
await changePassword({
|
||||||
const res = await fetch(path, {
|
password,
|
||||||
headers,
|
confirmPassword,
|
||||||
body: JSON.stringify({ password, confirmPassword }),
|
});
|
||||||
method: 'POST',
|
setToastData({
|
||||||
credentials: 'include',
|
title: 'Password changed successfully',
|
||||||
|
text: 'Now you can sign in using your new password.',
|
||||||
|
type: 'success',
|
||||||
});
|
});
|
||||||
handleResponse(res);
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setError(formatUnknownError(error));
|
const formattedError = formatUnknownError(error);
|
||||||
|
setError(formattedError);
|
||||||
|
setToastApiError(formattedError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResponse = (res: Response) => {
|
|
||||||
if (res.status === BAD_REQUEST) {
|
|
||||||
setError(PASSWORD_FORMAT_MESSAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === UNAUTHORIZED) {
|
|
||||||
setError('You are not authorized to make this request.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === NOT_FOUND) {
|
|
||||||
setError(
|
|
||||||
'The resource you requested could not be found on the server.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.status === OK) {
|
|
||||||
setEditingProfile(false);
|
|
||||||
setUpdatedPassword(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container} ref={ref}>
|
<PageContent isLoading={loading} header="Change password">
|
||||||
<Typography
|
<StyledForm>
|
||||||
variant="body1"
|
|
||||||
className={styles.editProfileTitle}
|
|
||||||
data-loading
|
|
||||||
>
|
|
||||||
Update password
|
|
||||||
</Typography>
|
|
||||||
<form
|
|
||||||
className={classnames(styles.form, themeStyles.contentSpacingY)}
|
|
||||||
>
|
|
||||||
<PasswordChecker
|
<PasswordChecker
|
||||||
password={password}
|
password={password}
|
||||||
callback={setValidPassword}
|
callback={setValidPassword}
|
||||||
@ -132,23 +94,12 @@ const EditProfile = ({
|
|||||||
data-loading
|
data-loading
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
className={styles.button}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</StyledForm>
|
||||||
data-loading
|
</PageContent>
|
||||||
className={styles.button}
|
|
||||||
type="submit"
|
|
||||||
onClick={() => setEditingProfile(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditProfile;
|
|
@ -0,0 +1,244 @@
|
|||||||
|
import { Button, styled, Typography } from '@mui/material';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||||
|
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import SelectMenu from 'component/common/select';
|
||||||
|
import { formatDateYMD } from 'utils/formatDate';
|
||||||
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
|
||||||
|
const StyledForm = styled('form')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledExpirationPicker = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1.5),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
||||||
|
minWidth: theme.spacing(20),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
marginTop: theme.spacing(4),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
enum ExpirationOption {
|
||||||
|
'7DAYS' = '7d',
|
||||||
|
'30DAYS' = '30d',
|
||||||
|
'60DAYS' = '60d',
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationOptions = [
|
||||||
|
{
|
||||||
|
key: ExpirationOption['7DAYS'],
|
||||||
|
days: 7,
|
||||||
|
label: '7 days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExpirationOption['30DAYS'],
|
||||||
|
days: 30,
|
||||||
|
label: '30 days',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ExpirationOption['60DAYS'],
|
||||||
|
days: 60,
|
||||||
|
label: '60 days',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ICreatePersonalAPITokenProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
newToken: (token: IPersonalAPIToken) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
newToken,
|
||||||
|
}) => {
|
||||||
|
const { refetchTokens } = usePersonalAPITokens();
|
||||||
|
const { createPersonalAPIToken, loading } = usePersonalAPITokensApi();
|
||||||
|
const { setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { locationSettings } = useLocationSettings();
|
||||||
|
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [expiration, setExpiration] = useState<ExpirationOption>(
|
||||||
|
ExpirationOption['30DAYS']
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateDate = () => {
|
||||||
|
const expiresAt = new Date();
|
||||||
|
const expirationOption = expirationOptions.find(
|
||||||
|
({ key }) => key === expiration
|
||||||
|
);
|
||||||
|
if (expirationOption) {
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + expirationOption.days);
|
||||||
|
}
|
||||||
|
return expiresAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [expiresAt, setExpiresAt] = useState(calculateDate());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescription('');
|
||||||
|
setExpiration(ExpirationOption['30DAYS']);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpiresAt(calculateDate());
|
||||||
|
}, [expiration]);
|
||||||
|
|
||||||
|
const getPersonalAPITokenPayload = () => {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await createPersonalAPIToken(
|
||||||
|
getPersonalAPITokenPayload()
|
||||||
|
);
|
||||||
|
newToken(token);
|
||||||
|
refetchTokens();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => {
|
||||||
|
return `curl --location --request POST '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/user/tokens' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(getPersonalAPITokenPayload(), undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label="Create personal API token"
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
modal
|
||||||
|
title="Create personal API token"
|
||||||
|
description="Use personal API tokens to authenticate to the Unleash API as
|
||||||
|
yourself. A personal API token has the same access privileges as
|
||||||
|
your user."
|
||||||
|
documentationLink="https://docs.getunleash.io/reference/api-tokens-and-client-keys"
|
||||||
|
documentationLinkLabel="Tokens documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<StyledForm onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Describe what this token will be used for
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label="Description"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Token expiration date
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledExpirationPicker>
|
||||||
|
<StyledSelectMenu
|
||||||
|
name="expiration"
|
||||||
|
id="expiration"
|
||||||
|
label="Token will expire in"
|
||||||
|
value={expiration}
|
||||||
|
onChange={e =>
|
||||||
|
setExpiration(
|
||||||
|
e.target.value as ExpirationOption
|
||||||
|
)
|
||||||
|
}
|
||||||
|
options={expirationOptions}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(expiresAt)}
|
||||||
|
show={() => (
|
||||||
|
<Typography variant="body2">
|
||||||
|
Token will expire on{' '}
|
||||||
|
<strong>
|
||||||
|
{formatDateYMD(
|
||||||
|
expiresAt!,
|
||||||
|
locationSettings.locale
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledExpirationPicker>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Create token
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,59 @@
|
|||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||||
|
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
|
interface IDeletePersonalAPITokenProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
token?: IPersonalAPIToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeletePersonalAPIToken: FC<IDeletePersonalAPITokenProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
token,
|
||||||
|
}) => {
|
||||||
|
const { refetchTokens } = usePersonalAPITokens();
|
||||||
|
const { deletePersonalAPIToken } = usePersonalAPITokensApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
|
||||||
|
const onRemoveClick = async () => {
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await deletePersonalAPIToken(token?.secret);
|
||||||
|
refetchTokens();
|
||||||
|
setOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: 'Token deleted successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
primaryButtonText="Delete token"
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
onClick={onRemoveClick}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
title="Delete token?"
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
Any applications or scripts using this token "
|
||||||
|
<strong>{token?.description}</strong>" will no longer be able to
|
||||||
|
access the Unleash API. You cannot undo this action.
|
||||||
|
</Typography>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Alert, styled, Typography } from '@mui/material';
|
||||||
|
import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IPersonalAPITokenDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
token?: IPersonalAPIToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonalAPITokenDialog: FC<IPersonalAPITokenDialogProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
token,
|
||||||
|
}) => (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
secondaryButtonText="Close"
|
||||||
|
onClose={(_, muiCloseReason?: string) => {
|
||||||
|
if (!muiCloseReason) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Personal API token created"
|
||||||
|
>
|
||||||
|
<StyledAlert severity="info">
|
||||||
|
Make sure to copy your personal API token now. You won't be able to
|
||||||
|
see it again!
|
||||||
|
</StyledAlert>
|
||||||
|
<Typography variant="body1">Your token:</Typography>
|
||||||
|
<UserToken token={token?.secret || ''} />
|
||||||
|
</Dialogue>
|
||||||
|
);
|
@ -0,0 +1,329 @@
|
|||||||
|
import { Delete } from '@mui/icons-material';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||||
|
import { useSearch } from 'hooks/useSearch';
|
||||||
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
import { IUser } from 'interfaces/user';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
|
||||||
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
import { CreatePersonalAPIToken } from './CreatePersonalAPIToken/CreatePersonalAPIToken';
|
||||||
|
import { DeletePersonalAPIToken } from './DeletePersonalAPIToken/DeletePersonalAPIToken';
|
||||||
|
import { PersonalAPITokenDialog } from './PersonalAPITokenDialog/PersonalAPITokenDialog';
|
||||||
|
|
||||||
|
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTablePlaceholder = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(10, 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(4.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tokensPlaceholder: IPersonalAPIToken[] = Array(15).fill({
|
||||||
|
description: 'Short description of the feature',
|
||||||
|
type: '-',
|
||||||
|
createdAt: new Date(2022, 1, 1),
|
||||||
|
project: 'projectID',
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PageQueryType = Partial<
|
||||||
|
Record<'sort' | 'order' | 'search', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const defaultSort: SortingRule<string> = { id: 'createdAt' };
|
||||||
|
|
||||||
|
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||||
|
'PersonalAPITokensTable:v1',
|
||||||
|
defaultSort
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IPersonalAPITokensTabProps {
|
||||||
|
user: IUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PersonalAPITokensTab = ({ user }: IPersonalAPITokensTabProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const { tokens = [], loading } = usePersonalAPITokens();
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [
|
||||||
|
{
|
||||||
|
id: searchParams.get('sort') || storedParams.id,
|
||||||
|
desc: searchParams.has('order')
|
||||||
|
? searchParams.get('order') === 'desc'
|
||||||
|
: storedParams.desc,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
globalFilter: searchParams.get('search') || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [selectedToken, setSelectedToken] = useState<IPersonalAPIToken>();
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Description',
|
||||||
|
accessor: 'description',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Expires',
|
||||||
|
accessor: 'expiresAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Created',
|
||||||
|
accessor: 'createdAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Actions',
|
||||||
|
id: 'Actions',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original: rowToken } }: any) => (
|
||||||
|
<ActionCell>
|
||||||
|
<Tooltip title="Delete token" arrow describeChild>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedToken(rowToken);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[setSelectedToken, setDeleteOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: searchedData,
|
||||||
|
getSearchText,
|
||||||
|
getSearchContext,
|
||||||
|
} = useSearch(columns, searchValue, tokens);
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
searchedData?.length === 0 && loading
|
||||||
|
? tokensPlaceholder
|
||||||
|
: searchedData,
|
||||||
|
[searchedData, loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
headerGroups,
|
||||||
|
rows,
|
||||||
|
prepareRow,
|
||||||
|
state: { sortBy },
|
||||||
|
setHiddenColumns,
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hiddenColumns = [];
|
||||||
|
if (isSmallScreen) {
|
||||||
|
hiddenColumns.push('createdAt');
|
||||||
|
}
|
||||||
|
if (isExtraSmallScreen) {
|
||||||
|
hiddenColumns.push('expiresAt');
|
||||||
|
}
|
||||||
|
setHiddenColumns(hiddenColumns);
|
||||||
|
}, [setHiddenColumns, isSmallScreen, isExtraSmallScreen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableState: PageQueryType = {};
|
||||||
|
tableState.sort = sortBy[0].id;
|
||||||
|
if (sortBy[0].desc) {
|
||||||
|
tableState.order = 'desc';
|
||||||
|
}
|
||||||
|
if (searchValue) {
|
||||||
|
tableState.search = searchValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(tableState, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||||
|
}, [sortBy, searchValue, setSearchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={`Personal API tokens (${
|
||||||
|
rows.length < data.length
|
||||||
|
? `${rows.length} of ${data.length}`
|
||||||
|
: data.length
|
||||||
|
})`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
New token
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StyledAlert severity="info">
|
||||||
|
Use personal API tokens to authenticate to the Unleash API as
|
||||||
|
yourself. A personal API token has the same access privileges as
|
||||||
|
your user.
|
||||||
|
</StyledAlert>
|
||||||
|
<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 tokens found matching “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledTablePlaceholder>
|
||||||
|
<StyledPlaceholderTitle>
|
||||||
|
You have no personal API tokens yet.
|
||||||
|
</StyledPlaceholderTitle>
|
||||||
|
<StyledPlaceholderSubtitle variant="body2">
|
||||||
|
Need an API token for scripts or testing?
|
||||||
|
Create a personal API token for quick access
|
||||||
|
to the Unleash API.
|
||||||
|
</StyledPlaceholderSubtitle>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
>
|
||||||
|
Create your first token
|
||||||
|
</Button>
|
||||||
|
</StyledTablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CreatePersonalAPIToken
|
||||||
|
open={createOpen}
|
||||||
|
setOpen={setCreateOpen}
|
||||||
|
newToken={(token: IPersonalAPIToken) => {
|
||||||
|
setSelectedToken(token);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PersonalAPITokenDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
setOpen={setDialogOpen}
|
||||||
|
token={selectedToken}
|
||||||
|
/>
|
||||||
|
<DeletePersonalAPIToken
|
||||||
|
open={deleteOpen}
|
||||||
|
setOpen={setDeleteOpen}
|
||||||
|
token={selectedToken}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
64
frontend/src/component/user/Profile/Profile.tsx
Normal file
64
frontend/src/component/user/Profile/Profile.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { ITab, VerticalTabs } from 'component/common/VerticalTabs/VerticalTabs';
|
||||||
|
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { PasswordTab } from './PasswordTab/PasswordTab';
|
||||||
|
import { PersonalAPITokensTab } from './PersonalAPITokensTab/PersonalAPITokensTab';
|
||||||
|
import { ProfileTab } from './ProfileTab/ProfileTab';
|
||||||
|
|
||||||
|
export const Profile = () => {
|
||||||
|
const { user } = useAuthUser();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'profile', label: 'Profile' },
|
||||||
|
{ id: 'password', label: 'Change password', path: 'change-password' },
|
||||||
|
{
|
||||||
|
id: 'pat',
|
||||||
|
label: 'Personal API tokens',
|
||||||
|
path: 'personal-api-tokens',
|
||||||
|
hidden: !uiConfig.flags.personalAccessTokens,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const onChange = (tab: ITab) => {
|
||||||
|
navigate(tab.path ? `/profile/${tab.path}` : '/profile', {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
setTab(tab.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabFromUrl = () => {
|
||||||
|
const url = location.pathname;
|
||||||
|
const foundTab = tabs.find(({ path }) => path && url.includes(path));
|
||||||
|
return (foundTab || tabs[0]).id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tab, setTab] = useState(tabFromUrl());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTab(tabFromUrl());
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalTabs tabs={tabs} value={tab} onChange={onChange}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={tab === 'profile'}
|
||||||
|
show={<ProfileTab user={user!} />}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={tab === 'password'}
|
||||||
|
show={<PasswordTab />}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={tab === 'pat'}
|
||||||
|
show={<PersonalAPITokensTab user={user!} />}
|
||||||
|
/>
|
||||||
|
</VerticalTabs>
|
||||||
|
);
|
||||||
|
};
|
221
frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx
Normal file
221
frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
SelectChangeEvent,
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||||
|
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
|
||||||
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
|
import { IUser } from 'interfaces/user';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
const StyledHeader = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(6),
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
backgroundColor: theme.palette.primary.main,
|
||||||
|
color: theme.palette.text.tertiaryContrast,
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
boxShadow: theme.boxShadows.primaryHeader,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInfo = styled('div')(() => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInfoName = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.spacing(3.75),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAvatar = styled(UserAvatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(9.5),
|
||||||
|
height: theme.spacing(9.5),
|
||||||
|
marginRight: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSectionLabel = styled(Typography)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAccess = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
'& > div > p': {
|
||||||
|
marginBottom: theme.spacing(1.5),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDivider = styled('div')(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
height: '1px',
|
||||||
|
backgroundColor: theme.palette.divider,
|
||||||
|
margin: theme.spacing(3, 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledFormControl = styled(FormControl)(({ theme }) => ({
|
||||||
|
marginTop: theme.spacing(1.5),
|
||||||
|
width: theme.spacing(30),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputLabel = styled(InputLabel)(({ theme }) => ({
|
||||||
|
backgroundColor: theme.palette.inputLabelBackground,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProfileTabProps {
|
||||||
|
user: IUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProfileTab = ({ user }: IProfileTabProps) => {
|
||||||
|
const { profile } = useProfile();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { locationSettings, setLocationSettings } = useLocationSettings();
|
||||||
|
const [currentLocale, setCurrentLocale] = useState<string>();
|
||||||
|
|
||||||
|
const [possibleLocales, setPossibleLocales] = useState([
|
||||||
|
'en-US',
|
||||||
|
'en-GB',
|
||||||
|
'nb-NO',
|
||||||
|
'sv-SE',
|
||||||
|
'da-DK',
|
||||||
|
'en-IN',
|
||||||
|
'de',
|
||||||
|
'cs',
|
||||||
|
'pt-BR',
|
||||||
|
'fr-FR',
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const found = possibleLocales.find(locale =>
|
||||||
|
locale.toLowerCase().includes(locationSettings.locale.toLowerCase())
|
||||||
|
);
|
||||||
|
setCurrentLocale(found);
|
||||||
|
if (!found) {
|
||||||
|
setPossibleLocales(prev => [...prev, locationSettings.locale]);
|
||||||
|
}
|
||||||
|
}, [locationSettings]);
|
||||||
|
|
||||||
|
const changeLocale = (e: SelectChangeEvent) => {
|
||||||
|
const locale = e.target.value;
|
||||||
|
setCurrentLocale(locale);
|
||||||
|
setLocationSettings({ locale });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledHeader>
|
||||||
|
<StyledAvatar user={user} />
|
||||||
|
<StyledInfo>
|
||||||
|
<StyledInfoName>
|
||||||
|
{user.name || user.username}
|
||||||
|
</StyledInfoName>
|
||||||
|
<Typography variant="body1">{user.email}</Typography>
|
||||||
|
</StyledInfo>
|
||||||
|
</StyledHeader>
|
||||||
|
<PageContent>
|
||||||
|
<StyledSectionLabel>Access</StyledSectionLabel>
|
||||||
|
<StyledAccess>
|
||||||
|
<Box sx={{ width: '50%' }}>
|
||||||
|
<Typography variant="body2">Your root role</Typography>
|
||||||
|
<Tooltip
|
||||||
|
title={profile?.rootRole.description || ''}
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
color="success"
|
||||||
|
icon={<InfoOutlinedIcon />}
|
||||||
|
iconRight
|
||||||
|
>
|
||||||
|
{profile?.rootRole.name}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">Projects</Typography>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(profile?.projects.length)}
|
||||||
|
show={profile?.projects.map(project => (
|
||||||
|
<Tooltip
|
||||||
|
key={project}
|
||||||
|
title="View project"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
>
|
||||||
|
<StyledBadge
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/projects/${project}`);
|
||||||
|
}}
|
||||||
|
color="secondary"
|
||||||
|
icon={<TopicOutlinedIcon />}
|
||||||
|
>
|
||||||
|
{project}
|
||||||
|
</StyledBadge>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
elseShow={
|
||||||
|
<Tooltip
|
||||||
|
title="You are not assigned to any projects"
|
||||||
|
arrow
|
||||||
|
describeChild
|
||||||
|
>
|
||||||
|
<Badge>No projects</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</StyledAccess>
|
||||||
|
<StyledDivider />
|
||||||
|
<StyledSectionLabel>Settings</StyledSectionLabel>
|
||||||
|
<Typography variant="body2">
|
||||||
|
This is the format used across the system for time and date
|
||||||
|
</Typography>
|
||||||
|
<StyledFormControl variant="outlined" size="small">
|
||||||
|
<StyledInputLabel htmlFor="locale-select">
|
||||||
|
Date/Time formatting
|
||||||
|
</StyledInputLabel>
|
||||||
|
<Select
|
||||||
|
id="locale-select"
|
||||||
|
value={currentLocale || ''}
|
||||||
|
native
|
||||||
|
onChange={changeLocale}
|
||||||
|
MenuProps={{
|
||||||
|
style: {
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{possibleLocales.map(locale => {
|
||||||
|
return (
|
||||||
|
<option key={locale} value={locale}>
|
||||||
|
{locale}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</StyledFormControl>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,24 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
container: {
|
|
||||||
width: '100%',
|
|
||||||
transform: 'translateY(-30px)',
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
width: '100%',
|
|
||||||
'& > *': {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
editProfileTitle: {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
width: '150px',
|
|
||||||
marginTop: '1.15rem',
|
|
||||||
[theme.breakpoints.down('md')]: {
|
|
||||||
width: '100px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,63 +1,31 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Avatar, Button, ClickAwayListener } from '@mui/material';
|
import { Button, ClickAwayListener, styled } from '@mui/material';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import { useStyles } from 'component/user/UserProfile/UserProfile.styles';
|
import { useStyles } from 'component/user/UserProfile/UserProfile.styles';
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
import { useThemeStyles } from 'themes/themeStyles';
|
||||||
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
import { UserProfileContent } from './UserProfileContent/UserProfileContent';
|
||||||
import { IUser } from 'interfaces/user';
|
import { IUser } from 'interfaces/user';
|
||||||
import { ILocationSettings } from 'hooks/useLocationSettings';
|
|
||||||
import { HEADER_USER_AVATAR } from 'utils/testIds';
|
import { HEADER_USER_AVATAR } from 'utils/testIds';
|
||||||
import unknownUser from 'assets/icons/unknownUser.png';
|
|
||||||
import { useId } from 'hooks/useId';
|
import { useId } from 'hooks/useId';
|
||||||
|
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||||
|
|
||||||
|
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4.5),
|
||||||
|
height: theme.spacing(4.5),
|
||||||
|
}));
|
||||||
|
|
||||||
interface IUserProfileProps {
|
interface IUserProfileProps {
|
||||||
profile: IUser;
|
profile: IUser;
|
||||||
locationSettings: ILocationSettings;
|
|
||||||
setLocationSettings: React.Dispatch<
|
|
||||||
React.SetStateAction<ILocationSettings>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfile = ({
|
const UserProfile = ({ profile }: IUserProfileProps) => {
|
||||||
profile,
|
|
||||||
locationSettings,
|
|
||||||
setLocationSettings,
|
|
||||||
}: IUserProfileProps) => {
|
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [currentLocale, setCurrentLocale] = useState<string>();
|
|
||||||
const modalId = useId();
|
const modalId = useId();
|
||||||
|
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
const { classes: themeStyles } = useThemeStyles();
|
||||||
|
|
||||||
const [possibleLocales, setPossibleLocales] = useState([
|
|
||||||
'en-US',
|
|
||||||
'en-GB',
|
|
||||||
'nb-NO',
|
|
||||||
'sv-SE',
|
|
||||||
'da-DK',
|
|
||||||
'en-IN',
|
|
||||||
'de',
|
|
||||||
'cs',
|
|
||||||
'pt-BR',
|
|
||||||
'fr-FR',
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let found = possibleLocales.find(l =>
|
|
||||||
l.toLowerCase().includes(locationSettings.locale.toLowerCase())
|
|
||||||
);
|
|
||||||
setCurrentLocale(found);
|
|
||||||
if (!found) {
|
|
||||||
setPossibleLocales(prev => [...prev, locationSettings.locale]);
|
|
||||||
}
|
|
||||||
/* eslint-disable-next-line*/
|
|
||||||
}, [locationSettings]);
|
|
||||||
|
|
||||||
const email = profile ? profile.email : '';
|
|
||||||
const imageUrl = email ? profile.imageUrl : unknownUser;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClickAwayListener onClickAway={() => setShowProfile(false)}>
|
<ClickAwayListener onClickAway={() => setShowProfile(false)}>
|
||||||
<div className={styles.profileContainer}>
|
<div className={styles.profileContainer}>
|
||||||
@ -74,10 +42,8 @@ const UserProfile = ({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
disableRipple
|
disableRipple
|
||||||
>
|
>
|
||||||
<Avatar
|
<StyledUserAvatar
|
||||||
style={{ backgroundColor: '#fff' }}
|
user={profile}
|
||||||
alt="Your Gravatar"
|
|
||||||
src={imageUrl}
|
|
||||||
data-testid={HEADER_USER_AVATAR}
|
data-testid={HEADER_USER_AVATAR}
|
||||||
/>
|
/>
|
||||||
<KeyboardArrowDownIcon className={styles.icon} />
|
<KeyboardArrowDownIcon className={styles.icon} />
|
||||||
@ -85,12 +51,8 @@ const UserProfile = ({
|
|||||||
<UserProfileContent
|
<UserProfileContent
|
||||||
id={modalId}
|
id={modalId}
|
||||||
showProfile={showProfile}
|
showProfile={showProfile}
|
||||||
imageUrl={imageUrl}
|
setShowProfile={setShowProfile}
|
||||||
profile={profile}
|
profile={profile}
|
||||||
setLocationSettings={setLocationSettings}
|
|
||||||
possibleLocales={possibleLocales}
|
|
||||||
setCurrentLocale={setCurrentLocale}
|
|
||||||
currentLocale={currentLocale}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
profile: {
|
|
||||||
position: 'absolute',
|
|
||||||
zIndex: 5000,
|
|
||||||
minWidth: '300px',
|
|
||||||
right: 0,
|
|
||||||
padding: '1.5rem',
|
|
||||||
[theme.breakpoints.down('md')]: {
|
|
||||||
width: '100%',
|
|
||||||
padding: '1rem',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
transition: 'transform 0.4s ease',
|
|
||||||
},
|
|
||||||
editingAvatar: {
|
|
||||||
transform: 'translateX(-102px) translateY(-9px)',
|
|
||||||
},
|
|
||||||
profileEmail: {
|
|
||||||
transition: 'transform 0.4s ease',
|
|
||||||
},
|
|
||||||
editingEmail: {
|
|
||||||
transform: 'translateX(10px) translateY(-60px)',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
textDecoration: 'none',
|
|
||||||
textAlign: 'left',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,192 +1,136 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import {
|
import { Button, Paper, Typography, styled, Link } from '@mui/material';
|
||||||
Avatar,
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
SelectChangeEvent,
|
|
||||||
Alert,
|
|
||||||
} from '@mui/material';
|
|
||||||
import classnames from 'classnames';
|
|
||||||
import { useStyles } from 'component/user/UserProfile/UserProfileContent/UserProfileContent.styles';
|
|
||||||
import { useThemeStyles } from 'themes/themeStyles';
|
|
||||||
import EditProfile from '../EditProfile/EditProfile';
|
|
||||||
import legacyStyles from '../../user.module.scss';
|
|
||||||
import { basePath } from 'utils/formatPath';
|
import { basePath } from 'utils/formatPath';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
||||||
import { IUser } from 'interfaces/user';
|
import { IUser } from 'interfaces/user';
|
||||||
import { ILocationSettings } from 'hooks/useLocationSettings';
|
import OpenInNew from '@mui/icons-material/OpenInNew';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||||
|
|
||||||
|
const StyledPaper = styled(Paper)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
boxShadow: theme.boxShadows.popup,
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 5000,
|
||||||
|
minWidth: theme.spacing(37.5),
|
||||||
|
right: 0,
|
||||||
|
[theme.breakpoints.down('md')]: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '1rem',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledProfileInfo = styled('div')(({ theme }) => ({
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4.75),
|
||||||
|
height: theme.spacing(4.75),
|
||||||
|
marginRight: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSubtitle = styled(Typography)(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledLink = styled(Link<typeof RouterLink | 'a'>)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
padding: 0,
|
||||||
|
color: theme.palette.primary.dark,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
'&:hover, &:focus': {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
'& svg': {
|
||||||
|
fontSize: theme.spacing(2.25),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledLinkPrivacy = styled(StyledLink)(({ theme }) => ({
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledLogoutButton = styled(Button)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
height: theme.spacing(5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDivider = styled('div')(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
height: '1px',
|
||||||
|
backgroundColor: theme.palette.divider,
|
||||||
|
margin: theme.spacing(3, 0),
|
||||||
|
}));
|
||||||
|
|
||||||
interface IUserProfileContentProps {
|
interface IUserProfileContentProps {
|
||||||
id: string;
|
id: string;
|
||||||
showProfile: boolean;
|
showProfile: boolean;
|
||||||
|
setShowProfile: (showProfile: boolean) => void;
|
||||||
profile: IUser;
|
profile: IUser;
|
||||||
possibleLocales: string[];
|
|
||||||
imageUrl: string;
|
|
||||||
currentLocale?: string;
|
|
||||||
setCurrentLocale: (value: string) => void;
|
|
||||||
setLocationSettings: React.Dispatch<
|
|
||||||
React.SetStateAction<ILocationSettings>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfileContent = ({
|
export const UserProfileContent = ({
|
||||||
id,
|
id,
|
||||||
showProfile,
|
showProfile,
|
||||||
|
setShowProfile,
|
||||||
profile,
|
profile,
|
||||||
possibleLocales,
|
}: IUserProfileContentProps) => (
|
||||||
imageUrl,
|
<ConditionallyRender
|
||||||
currentLocale,
|
condition={showProfile}
|
||||||
setCurrentLocale,
|
show={
|
||||||
setLocationSettings,
|
<StyledPaper id={id}>
|
||||||
}: IUserProfileContentProps) => {
|
<StyledProfileInfo>
|
||||||
const { classes: themeStyles } = useThemeStyles();
|
<StyledUserAvatar user={profile} />
|
||||||
const theme = useTheme();
|
<div>
|
||||||
|
<Typography>
|
||||||
|
{profile.name || profile.username}
|
||||||
|
</Typography>
|
||||||
|
<StyledSubtitle variant="body2">
|
||||||
|
{profile.email}
|
||||||
|
</StyledSubtitle>
|
||||||
|
</div>
|
||||||
|
</StyledProfileInfo>
|
||||||
|
|
||||||
const { uiConfig } = useUiConfig();
|
<StyledLink
|
||||||
const [updatedPassword, setUpdatedPassword] = useState(false);
|
component={RouterLink}
|
||||||
const [editingProfile, setEditingProfile] = useState(false);
|
to="/profile"
|
||||||
const { classes: styles } = useStyles();
|
underline="hover"
|
||||||
|
onClick={() => setShowProfile(false)}
|
||||||
const profileAvatarClasses = classnames(styles.avatar, {
|
|
||||||
[styles.editingAvatar]: editingProfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
const profileEmailClasses = classnames(styles.profileEmail, {
|
|
||||||
[styles.editingEmail]: editingProfile,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChange = (e: SelectChangeEvent) => {
|
|
||||||
const locale = e.target.value;
|
|
||||||
setCurrentLocale(locale);
|
|
||||||
setLocationSettings({ locale });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={showProfile}
|
|
||||||
show={
|
|
||||||
<Paper
|
|
||||||
id={id}
|
|
||||||
className={classnames(
|
|
||||||
styles.profile,
|
|
||||||
themeStyles.flexColumn,
|
|
||||||
themeStyles.itemsCenter,
|
|
||||||
themeStyles.contentSpacingY
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Avatar
|
View profile settings
|
||||||
alt="Your Gravatar"
|
</StyledLink>
|
||||||
src={imageUrl}
|
|
||||||
className={profileAvatarClasses}
|
|
||||||
/>
|
|
||||||
<Typography variant="body1" className={profileEmailClasses}>
|
|
||||||
{profile?.email}
|
|
||||||
</Typography>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={updatedPassword}
|
|
||||||
show={
|
|
||||||
<Alert onClose={() => setUpdatedPassword(false)}>
|
|
||||||
Successfully updated password.
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={!editingProfile}
|
|
||||||
show={
|
|
||||||
<>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={!uiConfig.disablePasswordAuth}
|
|
||||||
show={
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
setEditingProfile(true)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Update password
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className={themeStyles.divider} />
|
|
||||||
<div className={legacyStyles.showUserSettings}>
|
|
||||||
<FormControl
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
minWidth: '120px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<InputLabel
|
|
||||||
htmlFor="locale-select"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
theme.palette
|
|
||||||
.inputLabelBackground,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Date/Time formatting
|
|
||||||
</InputLabel>
|
|
||||||
<Select
|
|
||||||
id="locale-select"
|
|
||||||
value={currentLocale || ''}
|
|
||||||
native
|
|
||||||
onChange={handleChange}
|
|
||||||
MenuProps={{
|
|
||||||
style: {
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{possibleLocales.map(locale => {
|
|
||||||
return (
|
|
||||||
<option
|
|
||||||
key={locale}
|
|
||||||
value={locale}
|
|
||||||
>
|
|
||||||
{locale}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
<div className={themeStyles.divider} />
|
|
||||||
<a
|
|
||||||
className={styles.link}
|
|
||||||
href="https://www.getunleash.io/privacy-policy"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Privacy policy
|
|
||||||
</a>
|
|
||||||
<div className={themeStyles.divider} />
|
|
||||||
|
|
||||||
<Button
|
<StyledDivider />
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
href={`${basePath}/logout`}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
elseShow={
|
|
||||||
<EditProfile
|
|
||||||
setEditingProfile={setEditingProfile}
|
|
||||||
setUpdatedPassword={setUpdatedPassword}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UserProfileContent;
|
<StyledLinkPrivacy
|
||||||
|
component="a"
|
||||||
|
href="https://www.getunleash.io/privacy-policy"
|
||||||
|
underline="hover"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Privacy Policy <OpenInNew />
|
||||||
|
</StyledLinkPrivacy>
|
||||||
|
|
||||||
|
<StyledDivider />
|
||||||
|
|
||||||
|
<StyledLogoutButton
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
href={`${basePath}/logout`}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</StyledLogoutButton>
|
||||||
|
</StyledPaper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
@ -1,22 +1,14 @@
|
|||||||
import UserProfile from './UserProfile';
|
import UserProfile from './UserProfile';
|
||||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
|
||||||
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||||
|
|
||||||
const UserProfileContainer = () => {
|
const UserProfileContainer = () => {
|
||||||
const { locationSettings, setLocationSettings } = useLocationSettings();
|
|
||||||
const { user } = useAuthUser();
|
const { user } = useAuthUser();
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <UserProfile profile={user} />;
|
||||||
<UserProfile
|
|
||||||
locationSettings={locationSettings}
|
|
||||||
setLocationSettings={setLocationSettings}
|
|
||||||
profile={user}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserProfileContainer;
|
export default UserProfileContainer;
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
|
interface IChangePasswordPayload {
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePasswordApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePassword = async (payload: IChangePasswordPayload) => {
|
||||||
|
const req = createRequest('api/admin/user/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await makeRequest(req.caller, req.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
changePassword,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,45 @@
|
|||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
|
interface ICreatePersonalApiTokenPayload {
|
||||||
|
description: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePersonalAPITokensApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPersonalAPIToken = async (
|
||||||
|
payload: ICreatePersonalApiTokenPayload
|
||||||
|
) => {
|
||||||
|
const req = createRequest('api/admin/user/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(req.caller, req.id);
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePersonalAPIToken = async (secret: string) => {
|
||||||
|
const req = createRequest(`api/admin/user/tokens/${secret}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await makeRequest(req.caller, req.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createPersonalAPIToken,
|
||||||
|
deletePersonalAPIToken,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,31 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
|
||||||
|
export interface IUsePersonalAPITokensOutput {
|
||||||
|
tokens?: IPersonalAPIToken[];
|
||||||
|
refetchTokens: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => {
|
||||||
|
const { data, error, mutate } = useSWR(
|
||||||
|
formatApiPath('api/admin/user/tokens'),
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens: data ? data.pats : undefined,
|
||||||
|
loading: !error && !data,
|
||||||
|
refetchTokens: () => mutate(),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetcher = (path: string) => {
|
||||||
|
return fetch(path)
|
||||||
|
.then(handleErrorResponses('Personal API Tokens'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
31
frontend/src/hooks/api/getters/useProfile/useProfile.ts
Normal file
31
frontend/src/hooks/api/getters/useProfile/useProfile.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IProfile } from 'interfaces/profile';
|
||||||
|
|
||||||
|
export interface IUseProfileOutput {
|
||||||
|
profile?: IProfile;
|
||||||
|
refetchProfile: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProfile = (): IUseProfileOutput => {
|
||||||
|
const { data, error, mutate } = useSWR(
|
||||||
|
formatApiPath('api/admin/user/profile'),
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile: data,
|
||||||
|
loading: !error && !data,
|
||||||
|
refetchProfile: () => mutate(),
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetcher = (path: string) => {
|
||||||
|
return fetch(path)
|
||||||
|
.then(handleErrorResponses('Profile'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
6
frontend/src/interfaces/personalAPIToken.ts
Normal file
6
frontend/src/interfaces/personalAPIToken.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface IPersonalAPIToken {
|
||||||
|
description: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
6
frontend/src/interfaces/profile.ts
Normal file
6
frontend/src/interfaces/profile.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import IRole from './role';
|
||||||
|
|
||||||
|
export interface IProfile {
|
||||||
|
rootRole: IRole;
|
||||||
|
projects: string[];
|
||||||
|
}
|
@ -41,6 +41,7 @@ export interface IFlags {
|
|||||||
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
publicSignup?: boolean;
|
publicSignup?: boolean;
|
||||||
|
personalAccessTokens?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -22,6 +22,8 @@ export default createTheme({
|
|||||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||||
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
||||||
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
||||||
|
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
|
||||||
|
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: 'Sen, Roboto, sans-serif',
|
fontFamily: 'Sen, Roboto, sans-serif',
|
||||||
|
@ -15,6 +15,8 @@ export default createTheme({
|
|||||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||||
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
||||||
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
||||||
|
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
|
||||||
|
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: 'Sen, Roboto, sans-serif',
|
fontFamily: 'Sen, Roboto, sans-serif',
|
||||||
|
@ -25,6 +25,8 @@ declare module '@mui/material/styles' {
|
|||||||
main: string;
|
main: string;
|
||||||
card: string;
|
card: string;
|
||||||
elevated: string;
|
elevated: string;
|
||||||
|
popup: string;
|
||||||
|
primaryHeader: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,6 +42,7 @@ export interface IExperimentalOptions {
|
|||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
batchMetrics?: boolean;
|
batchMetrics?: boolean;
|
||||||
anonymiseEventLog?: boolean;
|
anonymiseEventLog?: boolean;
|
||||||
|
personalAccessTokens?: boolean;
|
||||||
};
|
};
|
||||||
externalResolver: IExternalFlagResolver;
|
externalResolver: IExternalFlagResolver;
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ process.nextTick(async () => {
|
|||||||
batchMetrics: true,
|
batchMetrics: true,
|
||||||
anonymiseEventLog: false,
|
anonymiseEventLog: false,
|
||||||
responseTimeWithAppName: true,
|
responseTimeWithAppName: true,
|
||||||
|
personalAccessTokens: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
Loading…
Reference in New Issue
Block a user