mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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