mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01: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',
|
||||
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
||||
<TimeAgoCell
|
||||
value={user.seenAt}
|
||||
emptyText="Never"
|
||||
title={date => `Last login: ${date}`}
|
||||
/>
|
||||
),
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
|
@ -152,7 +152,11 @@ const UsersList = () => {
|
||||
Header: 'Last login',
|
||||
accessor: (row: any) => row.seenAt || '',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
||||
<TimeAgoCell
|
||||
value={user.seenAt}
|
||||
emptyText="Never"
|
||||
title={date => `Last login: ${date}`}
|
||||
/>
|
||||
),
|
||||
disableGlobalFilter: true,
|
||||
sortType: 'date',
|
||||
|
@ -14,6 +14,7 @@ type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
|
||||
interface IBadgeProps {
|
||||
color?: Color;
|
||||
icon?: ReactElement;
|
||||
iconRight?: boolean;
|
||||
className?: string;
|
||||
sx?: SxProps<Theme>;
|
||||
children?: ReactNode;
|
||||
@ -23,6 +24,7 @@ interface IBadgeProps {
|
||||
|
||||
interface IBadgeIconProps {
|
||||
color?: Color;
|
||||
iconRight?: boolean;
|
||||
}
|
||||
|
||||
const StyledBadge = styled('div')<IBadgeProps>(
|
||||
@ -41,18 +43,35 @@ const StyledBadge = styled('div')<IBadgeProps>(
|
||||
);
|
||||
|
||||
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
|
||||
({ theme, color = 'neutral' }) => ({
|
||||
({ theme, color = 'neutral', iconRight = false }) => ({
|
||||
display: 'flex',
|
||||
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(
|
||||
(
|
||||
{
|
||||
color = 'neutral',
|
||||
icon,
|
||||
iconRight,
|
||||
className,
|
||||
sx,
|
||||
children,
|
||||
@ -69,22 +88,14 @@ export const Badge: FC<IBadgeProps> = forwardRef(
|
||||
ref={ref}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon)}
|
||||
show={
|
||||
<StyledBadgeIcon color={color}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon?.props.sx)}
|
||||
show={icon}
|
||||
elseShow={() =>
|
||||
cloneElement(icon!, {
|
||||
sx: { fontSize: '16px' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</StyledBadgeIcon>
|
||||
}
|
||||
condition={Boolean(icon) && !Boolean(iconRight)}
|
||||
show={BadgeIcon(color, icon!)}
|
||||
/>
|
||||
{children}
|
||||
<ConditionallyRender
|
||||
condition={Boolean(icon) && Boolean(iconRight)}
|
||||
show={BadgeIcon(color, icon!, true)}
|
||||
/>
|
||||
</StyledBadge>
|
||||
)
|
||||
);
|
||||
|
@ -26,7 +26,8 @@ const BreadcrumbNav = () => {
|
||||
item !== 'features' &&
|
||||
item !== 'features2' &&
|
||||
item !== 'create-toggle' &&
|
||||
item !== 'settings'
|
||||
item !== 'settings' &&
|
||||
item !== 'profile'
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -9,26 +9,24 @@ interface ITimeAgoCellProps {
|
||||
value?: string | number | Date;
|
||||
live?: boolean;
|
||||
emptyText?: string;
|
||||
title?: (date: string) => string;
|
||||
}
|
||||
|
||||
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
|
||||
value,
|
||||
live = false,
|
||||
emptyText,
|
||||
title,
|
||||
}) => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
|
||||
if (!value) return <TextCell>{emptyText}</TextCell>;
|
||||
|
||||
const date = formatDateYMD(value, locationSettings.locale);
|
||||
|
||||
return (
|
||||
<TextCell>
|
||||
<Tooltip
|
||||
title={`Last login: ${formatDateYMD(
|
||||
value,
|
||||
locationSettings.locale
|
||||
)}`}
|
||||
arrow
|
||||
>
|
||||
<Tooltip title={title?.(date) ?? date} arrow>
|
||||
<Typography noWrap variant="body2" data-loading>
|
||||
<TimeAgo date={new Date(value)} live={live} title={''} />
|
||||
</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",
|
||||
"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 { CorsAdmin } from 'component/admin/cors';
|
||||
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
|
||||
import { Profile } from 'component/user/Profile/Profile';
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
// Splash
|
||||
@ -529,6 +530,13 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
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 */
|
||||
{
|
||||
|
@ -89,7 +89,11 @@ const columns = [
|
||||
Header: 'Last login',
|
||||
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TimeAgoCell value={user.seenAt} emptyText="Never" />
|
||||
<TimeAgoCell
|
||||
value={user.seenAt}
|
||||
emptyText="Never"
|
||||
title={date => `Last login: ${date}`}
|
||||
/>
|
||||
),
|
||||
sortType: 'date',
|
||||
maxWidth: 150,
|
||||
|
@ -1,41 +1,30 @@
|
||||
import React, { SyntheticEvent, useState } from 'react';
|
||||
import { Button, Typography } from '@mui/material';
|
||||
import classnames from 'classnames';
|
||||
import { useStyles } from './EditProfile.styles';
|
||||
import { useThemeStyles } from 'themes/themeStyles';
|
||||
import { Button, styled } from '@mui/material';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import PasswordField from 'component/common/PasswordField/PasswordField';
|
||||
import PasswordChecker, {
|
||||
PASSWORD_FORMAT_MESSAGE,
|
||||
} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
|
||||
import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
|
||||
import useLoading from 'hooks/useLoading';
|
||||
import {
|
||||
BAD_REQUEST,
|
||||
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 { usePasswordApi } from 'hooks/api/actions/usePasswordApi/usePasswordApi';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { SyntheticEvent, useState } from 'react';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
|
||||
interface IEditProfileProps {
|
||||
setEditingProfile: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setUpdatedPassword: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
const StyledForm = styled('form')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
maxWidth: theme.spacing(44),
|
||||
}));
|
||||
|
||||
const EditProfile = ({
|
||||
setEditingProfile,
|
||||
setUpdatedPassword,
|
||||
}: IEditProfileProps) => {
|
||||
const { classes: styles } = useStyles();
|
||||
const { classes: themeStyles } = useThemeStyles();
|
||||
export const PasswordTab = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const [validPassword, setValidPassword] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const ref = useLoading(loading);
|
||||
const { changePassword } = usePasswordApi();
|
||||
|
||||
const submit = async (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
@ -48,54 +37,27 @@ const EditProfile = ({
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const path = formatApiPath('api/admin/user/change-password');
|
||||
const res = await fetch(path, {
|
||||
headers,
|
||||
body: JSON.stringify({ password, confirmPassword }),
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
await changePassword({
|
||||
password,
|
||||
confirmPassword,
|
||||
});
|
||||
setToastData({
|
||||
title: 'Password changed successfully',
|
||||
text: 'Now you can sign in using your new password.',
|
||||
type: 'success',
|
||||
});
|
||||
handleResponse(res);
|
||||
} catch (error: unknown) {
|
||||
setError(formatUnknownError(error));
|
||||
const formattedError = formatUnknownError(error);
|
||||
setError(formattedError);
|
||||
setToastApiError(formattedError);
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<div className={styles.container} ref={ref}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
className={styles.editProfileTitle}
|
||||
data-loading
|
||||
>
|
||||
Update password
|
||||
</Typography>
|
||||
<form
|
||||
className={classnames(styles.form, themeStyles.contentSpacingY)}
|
||||
>
|
||||
<PageContent isLoading={loading} header="Change password">
|
||||
<StyledForm>
|
||||
<PasswordChecker
|
||||
password={password}
|
||||
callback={setValidPassword}
|
||||
@ -132,23 +94,12 @@ const EditProfile = ({
|
||||
data-loading
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className={styles.button}
|
||||
type="submit"
|
||||
onClick={submit}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
data-loading
|
||||
className={styles.button}
|
||||
type="submit"
|
||||
onClick={() => setEditingProfile(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</StyledForm>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
||||
|
||||
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 { Avatar, Button, ClickAwayListener } from '@mui/material';
|
||||
import { Button, ClickAwayListener, styled } from '@mui/material';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import { useStyles } from 'component/user/UserProfile/UserProfile.styles';
|
||||
import { useThemeStyles } from 'themes/themeStyles';
|
||||
import UserProfileContent from './UserProfileContent/UserProfileContent';
|
||||
import { UserProfileContent } from './UserProfileContent/UserProfileContent';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import { ILocationSettings } from 'hooks/useLocationSettings';
|
||||
import { HEADER_USER_AVATAR } from 'utils/testIds';
|
||||
import unknownUser from 'assets/icons/unknownUser.png';
|
||||
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 {
|
||||
profile: IUser;
|
||||
locationSettings: ILocationSettings;
|
||||
setLocationSettings: React.Dispatch<
|
||||
React.SetStateAction<ILocationSettings>
|
||||
>;
|
||||
}
|
||||
|
||||
const UserProfile = ({
|
||||
profile,
|
||||
locationSettings,
|
||||
setLocationSettings,
|
||||
}: IUserProfileProps) => {
|
||||
const UserProfile = ({ profile }: IUserProfileProps) => {
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [currentLocale, setCurrentLocale] = useState<string>();
|
||||
const modalId = useId();
|
||||
|
||||
const { classes: styles } = useStyles();
|
||||
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 (
|
||||
<ClickAwayListener onClickAway={() => setShowProfile(false)}>
|
||||
<div className={styles.profileContainer}>
|
||||
@ -74,10 +42,8 @@ const UserProfile = ({
|
||||
color="secondary"
|
||||
disableRipple
|
||||
>
|
||||
<Avatar
|
||||
style={{ backgroundColor: '#fff' }}
|
||||
alt="Your Gravatar"
|
||||
src={imageUrl}
|
||||
<StyledUserAvatar
|
||||
user={profile}
|
||||
data-testid={HEADER_USER_AVATAR}
|
||||
/>
|
||||
<KeyboardArrowDownIcon className={styles.icon} />
|
||||
@ -85,12 +51,8 @@ const UserProfile = ({
|
||||
<UserProfileContent
|
||||
id={modalId}
|
||||
showProfile={showProfile}
|
||||
imageUrl={imageUrl}
|
||||
setShowProfile={setShowProfile}
|
||||
profile={profile}
|
||||
setLocationSettings={setLocationSettings}
|
||||
possibleLocales={possibleLocales}
|
||||
setCurrentLocale={setCurrentLocale}
|
||||
currentLocale={currentLocale}
|
||||
/>
|
||||
</div>
|
||||
</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 {
|
||||
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 { Button, Paper, Typography, styled, Link } from '@mui/material';
|
||||
import { basePath } from 'utils/formatPath';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import { ILocationSettings } from 'hooks/useLocationSettings';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import OpenInNew from '@mui/icons-material/OpenInNew';
|
||||
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 {
|
||||
id: string;
|
||||
showProfile: boolean;
|
||||
setShowProfile: (showProfile: boolean) => void;
|
||||
profile: IUser;
|
||||
possibleLocales: string[];
|
||||
imageUrl: string;
|
||||
currentLocale?: string;
|
||||
setCurrentLocale: (value: string) => void;
|
||||
setLocationSettings: React.Dispatch<
|
||||
React.SetStateAction<ILocationSettings>
|
||||
>;
|
||||
}
|
||||
|
||||
const UserProfileContent = ({
|
||||
export const UserProfileContent = ({
|
||||
id,
|
||||
showProfile,
|
||||
setShowProfile,
|
||||
profile,
|
||||
possibleLocales,
|
||||
imageUrl,
|
||||
currentLocale,
|
||||
setCurrentLocale,
|
||||
setLocationSettings,
|
||||
}: IUserProfileContentProps) => {
|
||||
const { classes: themeStyles } = useThemeStyles();
|
||||
const theme = useTheme();
|
||||
}: IUserProfileContentProps) => (
|
||||
<ConditionallyRender
|
||||
condition={showProfile}
|
||||
show={
|
||||
<StyledPaper id={id}>
|
||||
<StyledProfileInfo>
|
||||
<StyledUserAvatar user={profile} />
|
||||
<div>
|
||||
<Typography>
|
||||
{profile.name || profile.username}
|
||||
</Typography>
|
||||
<StyledSubtitle variant="body2">
|
||||
{profile.email}
|
||||
</StyledSubtitle>
|
||||
</div>
|
||||
</StyledProfileInfo>
|
||||
|
||||
const { uiConfig } = useUiConfig();
|
||||
const [updatedPassword, setUpdatedPassword] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
const { classes: styles } = useStyles();
|
||||
|
||||
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
|
||||
)}
|
||||
<StyledLink
|
||||
component={RouterLink}
|
||||
to="/profile"
|
||||
underline="hover"
|
||||
onClick={() => setShowProfile(false)}
|
||||
>
|
||||
<Avatar
|
||||
alt="Your Gravatar"
|
||||
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} />
|
||||
View profile settings
|
||||
</StyledLink>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href={`${basePath}/logout`}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
elseShow={
|
||||
<EditProfile
|
||||
setEditingProfile={setEditingProfile}
|
||||
setUpdatedPassword={setUpdatedPassword}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Paper>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
<StyledDivider />
|
||||
|
||||
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 { useLocationSettings } from 'hooks/useLocationSettings';
|
||||
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
|
||||
|
||||
const UserProfileContainer = () => {
|
||||
const { locationSettings, setLocationSettings } = useLocationSettings();
|
||||
const { user } = useAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProfile
|
||||
locationSettings={locationSettings}
|
||||
setLocationSettings={setLocationSettings}
|
||||
profile={user}
|
||||
/>
|
||||
);
|
||||
return <UserProfile profile={user} />;
|
||||
};
|
||||
|
||||
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;
|
||||
embedProxyFrontend?: boolean;
|
||||
publicSignup?: boolean;
|
||||
personalAccessTokens?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -22,6 +22,8 @@ export default createTheme({
|
||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
||||
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: {
|
||||
fontFamily: 'Sen, Roboto, sans-serif',
|
||||
|
@ -15,6 +15,8 @@ export default createTheme({
|
||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
||||
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: {
|
||||
fontFamily: 'Sen, Roboto, sans-serif',
|
||||
|
@ -25,6 +25,8 @@ declare module '@mui/material/styles' {
|
||||
main: string;
|
||||
card: string;
|
||||
elevated: string;
|
||||
popup: string;
|
||||
primaryHeader: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -42,6 +42,7 @@ export interface IExperimentalOptions {
|
||||
embedProxyFrontend?: boolean;
|
||||
batchMetrics?: boolean;
|
||||
anonymiseEventLog?: boolean;
|
||||
personalAccessTokens?: boolean;
|
||||
};
|
||||
externalResolver: IExternalFlagResolver;
|
||||
}
|
||||
|
@ -38,6 +38,7 @@ process.nextTick(async () => {
|
||||
batchMetrics: true,
|
||||
anonymiseEventLog: false,
|
||||
responseTimeWithAppName: true,
|
||||
personalAccessTokens: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
Loading…
Reference in New Issue
Block a user