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