1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: new profile page and PATs front-end (#2109)

* feat: new user dropdown and profile page

* fix: add popup boxShadow to dark-theme

* fix: update routes snap

* refactor: move some tab specific logic into tabs component

* add useProfile hook example

* fix profile tab header (no name)

* fix: hide user popup when clicking profile link

* - add PATs to profile;
- add route logic to profile;
- refactor TimeAgoCell title;
- misc fixes and refactoring;

* add profile info to profile tab

* simplify req paths

* add PAT flag to the front-end

* fix: some UI adjustments

* change user popup buttons to links

* fix profile on front-end, add role description

* update delete PAT text

* address some PR comments

* address PR comments

* some more UI fixes and refactoring

* move password request to API hook
This commit is contained in:
Nuno Góis 2022-10-03 10:49:52 +01:00 committed by GitHub
parent 8099acd216
commit ddcfe132e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1445 additions and 400 deletions

View File

@ -115,7 +115,11 @@ export const Group: VFC = () => {
Header: 'Last login',
accessor: (row: IGroupUser) => row.seenAt || '',
Cell: ({ row: { original: user } }: any) => (
<TimeAgoCell value={user.seenAt} emptyText="Never" />
<TimeAgoCell
value={user.seenAt}
emptyText="Never"
title={date => `Last login: ${date}`}
/>
),
sortType: 'date',
maxWidth: 150,

View File

@ -152,7 +152,11 @@ const UsersList = () => {
Header: 'Last login',
accessor: (row: any) => row.seenAt || '',
Cell: ({ row: { original: user } }: any) => (
<TimeAgoCell value={user.seenAt} emptyText="Never" />
<TimeAgoCell
value={user.seenAt}
emptyText="Never"
title={date => `Last login: ${date}`}
/>
),
disableGlobalFilter: true,
sortType: 'date',

View File

@ -14,6 +14,7 @@ type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
interface IBadgeProps {
color?: Color;
icon?: ReactElement;
iconRight?: boolean;
className?: string;
sx?: SxProps<Theme>;
children?: ReactNode;
@ -23,6 +24,7 @@ interface IBadgeProps {
interface IBadgeIconProps {
color?: Color;
iconRight?: boolean;
}
const StyledBadge = styled('div')<IBadgeProps>(
@ -41,18 +43,35 @@ const StyledBadge = styled('div')<IBadgeProps>(
);
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
({ theme, color = 'neutral' }) => ({
({ theme, color = 'neutral', iconRight = false }) => ({
display: 'flex',
color: theme.palette[color].main,
marginRight: theme.spacing(0.5),
margin: iconRight
? theme.spacing(0, 0, 0, 0.5)
: theme.spacing(0, 0.5, 0, 0),
})
);
const BadgeIcon = (color: Color, icon: ReactElement, iconRight = false) => (
<StyledBadgeIcon color={color} iconRight={iconRight}>
<ConditionallyRender
condition={Boolean(icon?.props.sx)}
show={icon}
elseShow={() =>
cloneElement(icon!, {
sx: { fontSize: '16px' },
})
}
/>
</StyledBadgeIcon>
);
export const Badge: FC<IBadgeProps> = forwardRef(
(
{
color = 'neutral',
icon,
iconRight,
className,
sx,
children,
@ -69,22 +88,14 @@ export const Badge: FC<IBadgeProps> = forwardRef(
ref={ref}
>
<ConditionallyRender
condition={Boolean(icon)}
show={
<StyledBadgeIcon color={color}>
<ConditionallyRender
condition={Boolean(icon?.props.sx)}
show={icon}
elseShow={() =>
cloneElement(icon!, {
sx: { fontSize: '16px' },
})
}
/>
</StyledBadgeIcon>
}
condition={Boolean(icon) && !Boolean(iconRight)}
show={BadgeIcon(color, icon!)}
/>
{children}
<ConditionallyRender
condition={Boolean(icon) && Boolean(iconRight)}
show={BadgeIcon(color, icon!, true)}
/>
</StyledBadge>
)
);

View File

@ -26,7 +26,8 @@ const BreadcrumbNav = () => {
item !== 'features' &&
item !== 'features2' &&
item !== 'create-toggle' &&
item !== 'settings'
item !== 'settings' &&
item !== 'profile'
);
return (

View File

@ -9,26 +9,24 @@ interface ITimeAgoCellProps {
value?: string | number | Date;
live?: boolean;
emptyText?: string;
title?: (date: string) => string;
}
export const TimeAgoCell: VFC<ITimeAgoCellProps> = ({
value,
live = false,
emptyText,
title,
}) => {
const { locationSettings } = useLocationSettings();
if (!value) return <TextCell>{emptyText}</TextCell>;
const date = formatDateYMD(value, locationSettings.locale);
return (
<TextCell>
<Tooltip
title={`Last login: ${formatDateYMD(
value,
locationSettings.locale
)}`}
arrow
>
<Tooltip title={title?.(date) ?? date} arrow>
<Typography noWrap variant="body2" data-loading>
<TimeAgo date={new Date(value)} live={live} title={''} />
</Typography>

View File

@ -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>
);

View 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>
);

View File

@ -490,5 +490,12 @@ exports[`returns all baseRoutes 1`] = `
"title": "Admin",
"type": "protected",
},
{
"component": [Function],
"menu": {},
"path": "/profile/*",
"title": "Profile",
"type": "protected",
},
]
`;

View File

@ -58,6 +58,7 @@ import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
import { LazyPlayground } from 'component/playground/Playground/LazyPlayground';
import { CorsAdmin } from 'component/admin/cors';
import { InviteLink } from 'component/admin/users/InviteLink/InviteLink';
import { Profile } from 'component/user/Profile/Profile';
export const routes: IRoute[] = [
// Splash
@ -529,6 +530,13 @@ export const routes: IRoute[] = [
type: 'protected',
menu: {},
},
{
path: '/profile/*',
title: 'Profile',
component: Profile,
type: 'protected',
menu: {},
},
/* If you update this route path, make sure you update the path in SWRProvider.tsx */
{

View File

@ -89,7 +89,11 @@ const columns = [
Header: 'Last login',
accessor: (row: IGroupUser) => row.seenAt || '',
Cell: ({ row: { original: user } }: any) => (
<TimeAgoCell value={user.seenAt} emptyText="Never" />
<TimeAgoCell
value={user.seenAt}
emptyText="Never"
title={date => `Last login: ${date}`}
/>
),
sortType: 'date',
maxWidth: 150,

View File

@ -1,41 +1,30 @@
import React, { SyntheticEvent, useState } from 'react';
import { Button, Typography } from '@mui/material';
import classnames from 'classnames';
import { useStyles } from './EditProfile.styles';
import { useThemeStyles } from 'themes/themeStyles';
import { Button, styled } from '@mui/material';
import { PageContent } from 'component/common/PageContent/PageContent';
import PasswordField from 'component/common/PasswordField/PasswordField';
import PasswordChecker, {
PASSWORD_FORMAT_MESSAGE,
} from 'component/user/common/ResetPasswordForm/PasswordChecker/PasswordChecker';
import PasswordMatcher from 'component/user/common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
import useLoading from 'hooks/useLoading';
import {
BAD_REQUEST,
NOT_FOUND,
OK,
UNAUTHORIZED,
} from 'constants/statusCodes';
import { formatApiPath } from 'utils/formatPath';
import PasswordField from 'component/common/PasswordField/PasswordField';
import { headers } from 'utils/apiUtils';
import { usePasswordApi } from 'hooks/api/actions/usePasswordApi/usePasswordApi';
import useToast from 'hooks/useToast';
import { SyntheticEvent, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
interface IEditProfileProps {
setEditingProfile: React.Dispatch<React.SetStateAction<boolean>>;
setUpdatedPassword: React.Dispatch<React.SetStateAction<boolean>>;
}
const StyledForm = styled('form')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
maxWidth: theme.spacing(44),
}));
const EditProfile = ({
setEditingProfile,
setUpdatedPassword,
}: IEditProfileProps) => {
const { classes: styles } = useStyles();
const { classes: themeStyles } = useThemeStyles();
export const PasswordTab = () => {
const [loading, setLoading] = useState(false);
const { setToastData, setToastApiError } = useToast();
const [validPassword, setValidPassword] = useState(false);
const [error, setError] = useState<string>();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const ref = useLoading(loading);
const { changePassword } = usePasswordApi();
const submit = async (e: SyntheticEvent) => {
e.preventDefault();
@ -48,54 +37,27 @@ const EditProfile = ({
setLoading(true);
setError(undefined);
try {
const path = formatApiPath('api/admin/user/change-password');
const res = await fetch(path, {
headers,
body: JSON.stringify({ password, confirmPassword }),
method: 'POST',
credentials: 'include',
await changePassword({
password,
confirmPassword,
});
setToastData({
title: 'Password changed successfully',
text: 'Now you can sign in using your new password.',
type: 'success',
});
handleResponse(res);
} catch (error: unknown) {
setError(formatUnknownError(error));
const formattedError = formatUnknownError(error);
setError(formattedError);
setToastApiError(formattedError);
}
}
setLoading(false);
};
const handleResponse = (res: Response) => {
if (res.status === BAD_REQUEST) {
setError(PASSWORD_FORMAT_MESSAGE);
}
if (res.status === UNAUTHORIZED) {
setError('You are not authorized to make this request.');
}
if (res.status === NOT_FOUND) {
setError(
'The resource you requested could not be found on the server.'
);
}
if (res.status === OK) {
setEditingProfile(false);
setUpdatedPassword(true);
}
};
return (
<div className={styles.container} ref={ref}>
<Typography
variant="body1"
className={styles.editProfileTitle}
data-loading
>
Update password
</Typography>
<form
className={classnames(styles.form, themeStyles.contentSpacingY)}
>
<PageContent isLoading={loading} header="Change password">
<StyledForm>
<PasswordChecker
password={password}
callback={setValidPassword}
@ -132,23 +94,12 @@ const EditProfile = ({
data-loading
variant="contained"
color="primary"
className={styles.button}
type="submit"
onClick={submit}
>
Save
</Button>
<Button
data-loading
className={styles.button}
type="submit"
onClick={() => setEditingProfile(false)}
>
Cancel
</Button>
</form>
</div>
</StyledForm>
</PageContent>
);
};
export default EditProfile;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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 &ldquo;
{searchValue}
&rdquo;
</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>
);
};

View 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>
);
};

View 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>
</>
);
};

View File

@ -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',
},
},
}));

View File

@ -1,63 +1,31 @@
import React, { useEffect, useState } from 'react';
import { useState } from 'react';
import classnames from 'classnames';
import { Avatar, Button, ClickAwayListener } from '@mui/material';
import { Button, ClickAwayListener, styled } from '@mui/material';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import { useStyles } from 'component/user/UserProfile/UserProfile.styles';
import { useThemeStyles } from 'themes/themeStyles';
import UserProfileContent from './UserProfileContent/UserProfileContent';
import { UserProfileContent } from './UserProfileContent/UserProfileContent';
import { IUser } from 'interfaces/user';
import { ILocationSettings } from 'hooks/useLocationSettings';
import { HEADER_USER_AVATAR } from 'utils/testIds';
import unknownUser from 'assets/icons/unknownUser.png';
import { useId } from 'hooks/useId';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
width: theme.spacing(4.5),
height: theme.spacing(4.5),
}));
interface IUserProfileProps {
profile: IUser;
locationSettings: ILocationSettings;
setLocationSettings: React.Dispatch<
React.SetStateAction<ILocationSettings>
>;
}
const UserProfile = ({
profile,
locationSettings,
setLocationSettings,
}: IUserProfileProps) => {
const UserProfile = ({ profile }: IUserProfileProps) => {
const [showProfile, setShowProfile] = useState(false);
const [currentLocale, setCurrentLocale] = useState<string>();
const modalId = useId();
const { classes: styles } = useStyles();
const { classes: themeStyles } = useThemeStyles();
const [possibleLocales, setPossibleLocales] = useState([
'en-US',
'en-GB',
'nb-NO',
'sv-SE',
'da-DK',
'en-IN',
'de',
'cs',
'pt-BR',
'fr-FR',
]);
useEffect(() => {
let found = possibleLocales.find(l =>
l.toLowerCase().includes(locationSettings.locale.toLowerCase())
);
setCurrentLocale(found);
if (!found) {
setPossibleLocales(prev => [...prev, locationSettings.locale]);
}
/* eslint-disable-next-line*/
}, [locationSettings]);
const email = profile ? profile.email : '';
const imageUrl = email ? profile.imageUrl : unknownUser;
return (
<ClickAwayListener onClickAway={() => setShowProfile(false)}>
<div className={styles.profileContainer}>
@ -74,10 +42,8 @@ const UserProfile = ({
color="secondary"
disableRipple
>
<Avatar
style={{ backgroundColor: '#fff' }}
alt="Your Gravatar"
src={imageUrl}
<StyledUserAvatar
user={profile}
data-testid={HEADER_USER_AVATAR}
/>
<KeyboardArrowDownIcon className={styles.icon} />
@ -85,12 +51,8 @@ const UserProfile = ({
<UserProfileContent
id={modalId}
showProfile={showProfile}
imageUrl={imageUrl}
setShowProfile={setShowProfile}
profile={profile}
setLocationSettings={setLocationSettings}
possibleLocales={possibleLocales}
setCurrentLocale={setCurrentLocale}
currentLocale={currentLocale}
/>
</div>
</ClickAwayListener>

View File

@ -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%',
},
}));

View File

@ -1,192 +1,136 @@
import React, { useState } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
Avatar,
Button,
FormControl,
InputLabel,
Paper,
Select,
Typography,
SelectChangeEvent,
Alert,
} from '@mui/material';
import classnames from 'classnames';
import { useStyles } from 'component/user/UserProfile/UserProfileContent/UserProfileContent.styles';
import { useThemeStyles } from 'themes/themeStyles';
import EditProfile from '../EditProfile/EditProfile';
import legacyStyles from '../../user.module.scss';
import { Button, Paper, Typography, styled, Link } from '@mui/material';
import { basePath } from 'utils/formatPath';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { IUser } from 'interfaces/user';
import { ILocationSettings } from 'hooks/useLocationSettings';
import { useTheme } from '@mui/material/styles';
import OpenInNew from '@mui/icons-material/OpenInNew';
import { Link as RouterLink } from 'react-router-dom';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
const StyledPaper = styled(Paper)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadiusMedium,
boxShadow: theme.boxShadows.popup,
position: 'absolute',
zIndex: 5000,
minWidth: theme.spacing(37.5),
right: 0,
[theme.breakpoints.down('md')]: {
width: '100%',
padding: '1rem',
},
}));
const StyledProfileInfo = styled('div')(({ theme }) => ({
alignSelf: 'flex-start',
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(2),
}));
const StyledUserAvatar = styled(UserAvatar)(({ theme }) => ({
width: theme.spacing(4.75),
height: theme.spacing(4.75),
marginRight: theme.spacing(1.5),
}));
const StyledSubtitle = styled(Typography)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledLink = styled(Link<typeof RouterLink | 'a'>)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: 0,
color: theme.palette.primary.dark,
fontWeight: theme.fontWeight.medium,
'&:hover, &:focus': {
textDecoration: 'underline',
},
'& svg': {
fontSize: theme.spacing(2.25),
},
}));
const StyledLinkPrivacy = styled(StyledLink)(({ theme }) => ({
alignSelf: 'flex-start',
}));
const StyledLogoutButton = styled(Button)(({ theme }) => ({
width: '100%',
height: theme.spacing(5),
}));
const StyledDivider = styled('div')(({ theme }) => ({
width: '100%',
height: '1px',
backgroundColor: theme.palette.divider,
margin: theme.spacing(3, 0),
}));
interface IUserProfileContentProps {
id: string;
showProfile: boolean;
setShowProfile: (showProfile: boolean) => void;
profile: IUser;
possibleLocales: string[];
imageUrl: string;
currentLocale?: string;
setCurrentLocale: (value: string) => void;
setLocationSettings: React.Dispatch<
React.SetStateAction<ILocationSettings>
>;
}
const UserProfileContent = ({
export const UserProfileContent = ({
id,
showProfile,
setShowProfile,
profile,
possibleLocales,
imageUrl,
currentLocale,
setCurrentLocale,
setLocationSettings,
}: IUserProfileContentProps) => {
const { classes: themeStyles } = useThemeStyles();
const theme = useTheme();
}: IUserProfileContentProps) => (
<ConditionallyRender
condition={showProfile}
show={
<StyledPaper id={id}>
<StyledProfileInfo>
<StyledUserAvatar user={profile} />
<div>
<Typography>
{profile.name || profile.username}
</Typography>
<StyledSubtitle variant="body2">
{profile.email}
</StyledSubtitle>
</div>
</StyledProfileInfo>
const { uiConfig } = useUiConfig();
const [updatedPassword, setUpdatedPassword] = useState(false);
const [editingProfile, setEditingProfile] = useState(false);
const { classes: styles } = useStyles();
const profileAvatarClasses = classnames(styles.avatar, {
[styles.editingAvatar]: editingProfile,
});
const profileEmailClasses = classnames(styles.profileEmail, {
[styles.editingEmail]: editingProfile,
});
const handleChange = (e: SelectChangeEvent) => {
const locale = e.target.value;
setCurrentLocale(locale);
setLocationSettings({ locale });
};
return (
<ConditionallyRender
condition={showProfile}
show={
<Paper
id={id}
className={classnames(
styles.profile,
themeStyles.flexColumn,
themeStyles.itemsCenter,
themeStyles.contentSpacingY
)}
<StyledLink
component={RouterLink}
to="/profile"
underline="hover"
onClick={() => setShowProfile(false)}
>
<Avatar
alt="Your Gravatar"
src={imageUrl}
className={profileAvatarClasses}
/>
<Typography variant="body1" className={profileEmailClasses}>
{profile?.email}
</Typography>
<ConditionallyRender
condition={updatedPassword}
show={
<Alert onClose={() => setUpdatedPassword(false)}>
Successfully updated password.
</Alert>
}
/>
<ConditionallyRender
condition={!editingProfile}
show={
<>
<ConditionallyRender
condition={!uiConfig.disablePasswordAuth}
show={
<Button
onClick={() =>
setEditingProfile(true)
}
>
Update password
</Button>
}
/>
<div className={themeStyles.divider} />
<div className={legacyStyles.showUserSettings}>
<FormControl
variant="outlined"
size="small"
style={{
width: '100%',
minWidth: '120px',
}}
>
<InputLabel
htmlFor="locale-select"
style={{
backgroundColor:
theme.palette
.inputLabelBackground,
}}
>
Date/Time formatting
</InputLabel>
<Select
id="locale-select"
value={currentLocale || ''}
native
onChange={handleChange}
MenuProps={{
style: {
zIndex: 9999,
},
}}
>
{possibleLocales.map(locale => {
return (
<option
key={locale}
value={locale}
>
{locale}
</option>
);
})}
</Select>
</FormControl>
</div>
<div className={themeStyles.divider} />
<a
className={styles.link}
href="https://www.getunleash.io/privacy-policy"
rel="noopener noreferrer"
target="_blank"
>
Privacy policy
</a>
<div className={themeStyles.divider} />
View profile settings
</StyledLink>
<Button
variant="contained"
color="primary"
href={`${basePath}/logout`}
>
Logout
</Button>
</>
}
elseShow={
<EditProfile
setEditingProfile={setEditingProfile}
setUpdatedPassword={setUpdatedPassword}
/>
}
/>
</Paper>
}
/>
);
};
<StyledDivider />
export default UserProfileContent;
<StyledLinkPrivacy
component="a"
href="https://www.getunleash.io/privacy-policy"
underline="hover"
rel="noopener noreferrer"
target="_blank"
>
Privacy Policy <OpenInNew />
</StyledLinkPrivacy>
<StyledDivider />
<StyledLogoutButton
variant="outlined"
color="primary"
href={`${basePath}/logout`}
>
Logout
</StyledLogoutButton>
</StyledPaper>
}
/>
);

View File

@ -1,22 +1,14 @@
import UserProfile from './UserProfile';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { useAuthUser } from 'hooks/api/getters/useAuth/useAuthUser';
const UserProfileContainer = () => {
const { locationSettings, setLocationSettings } = useLocationSettings();
const { user } = useAuthUser();
if (!user) {
return null;
}
return (
<UserProfile
locationSettings={locationSettings}
setLocationSettings={setLocationSettings}
profile={user}
/>
);
return <UserProfile profile={user} />;
};
export default UserProfileContainer;

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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());
};

View 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());
};

View File

@ -0,0 +1,6 @@
export interface IPersonalAPIToken {
description: string;
expiresAt: string;
createdAt: string;
secret: string;
}

View File

@ -0,0 +1,6 @@
import IRole from './role';
export interface IProfile {
rootRole: IRole;
projects: string[];
}

View File

@ -41,6 +41,7 @@ export interface IFlags {
ENABLE_DARK_MODE_SUPPORT?: boolean;
embedProxyFrontend?: boolean;
publicSignup?: boolean;
personalAccessTokens?: boolean;
}
export interface IVersionInfo {

View File

@ -22,6 +22,8 @@ export default createTheme({
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
},
typography: {
fontFamily: 'Sen, Roboto, sans-serif',

View File

@ -15,6 +15,8 @@ export default createTheme({
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
popup: '0px 2px 6px rgba(0, 0, 0, 0.25)',
primaryHeader: '0px 8px 24px rgba(97, 91, 194, 0.2)',
},
typography: {
fontFamily: 'Sen, Roboto, sans-serif',

View File

@ -25,6 +25,8 @@ declare module '@mui/material/styles' {
main: string;
card: string;
elevated: string;
popup: string;
primaryHeader: string;
};
}

View File

@ -42,6 +42,7 @@ export interface IExperimentalOptions {
embedProxyFrontend?: boolean;
batchMetrics?: boolean;
anonymiseEventLog?: boolean;
personalAccessTokens?: boolean;
};
externalResolver: IExternalFlagResolver;
}

View File

@ -38,6 +38,7 @@ process.nextTick(async () => {
batchMetrics: true,
anonymiseEventLog: false,
responseTimeWithAppName: true,
personalAccessTokens: true,
},
},
authentication: {