From ddcfe132e40b97f4cf8b3d3db9930984354f44ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 3 Oct 2022 10:49:52 +0100 Subject: [PATCH] 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 --- .../component/admin/groups/Group/Group.tsx | 6 +- .../admin/users/UsersList/UsersList.tsx | 6 +- frontend/src/component/common/Badge/Badge.tsx | 43 ++- .../common/BreadcrumbNav/BreadcrumbNav.tsx | 3 +- .../Table/cells/TimeAgoCell/TimeAgoCell.tsx | 12 +- .../VerticalTabs/VerticalTab/VerticalTab.tsx | 40 +++ .../common/VerticalTabs/VerticalTabs.tsx | 64 ++++ .../__snapshots__/routes.test.tsx.snap | 7 + frontend/src/component/menu/routes.ts | 8 + .../ProjectGroupView/ProjectGroupView.tsx | 6 +- .../PasswordTab/PasswordTab.tsx} | 109 ++---- .../CreatePersonalAPIToken.tsx | 244 +++++++++++++ .../DeletePersonalAPIToken.tsx | 59 ++++ .../PersonalAPITokenDialog.tsx | 39 +++ .../PersonalAPITokensTab.tsx | 329 ++++++++++++++++++ .../src/component/user/Profile/Profile.tsx | 64 ++++ .../user/Profile/ProfileTab/ProfileTab.tsx | 221 ++++++++++++ .../EditProfile/EditProfile.styles.ts | 24 -- .../user/UserProfile/UserProfile.tsx | 64 +--- .../UserProfileContent.styles.ts | 35 -- .../UserProfileContent/UserProfileContent.tsx | 294 +++++++--------- .../src/component/user/UserProfile/index.tsx | 10 +- .../actions/usePasswordApi/usePasswordApi.ts | 30 ++ .../usePersonalAPITokensApi.ts | 45 +++ .../usePersonalAPITokens.ts | 31 ++ .../api/getters/useProfile/useProfile.ts | 31 ++ frontend/src/interfaces/personalAPIToken.ts | 6 + frontend/src/interfaces/profile.ts | 6 + frontend/src/interfaces/uiConfig.ts | 1 + frontend/src/themes/dark-theme.ts | 2 + frontend/src/themes/theme.ts | 2 + frontend/src/themes/themeTypes.ts | 2 + src/lib/types/experimental.ts | 1 + src/server-dev.ts | 1 + 34 files changed, 1445 insertions(+), 400 deletions(-) create mode 100644 frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx create mode 100644 frontend/src/component/common/VerticalTabs/VerticalTabs.tsx rename frontend/src/component/user/{UserProfile/EditProfile/EditProfile.tsx => Profile/PasswordTab/PasswordTab.tsx} (50%) create mode 100644 frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx create mode 100644 frontend/src/component/user/Profile/PersonalAPITokensTab/DeletePersonalAPIToken/DeletePersonalAPIToken.tsx create mode 100644 frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokenDialog/PersonalAPITokenDialog.tsx create mode 100644 frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx create mode 100644 frontend/src/component/user/Profile/Profile.tsx create mode 100644 frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx delete mode 100644 frontend/src/component/user/UserProfile/EditProfile/EditProfile.styles.ts delete mode 100644 frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.styles.ts create mode 100644 frontend/src/hooks/api/actions/usePasswordApi/usePasswordApi.ts create mode 100644 frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts create mode 100644 frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts create mode 100644 frontend/src/hooks/api/getters/useProfile/useProfile.ts create mode 100644 frontend/src/interfaces/personalAPIToken.ts create mode 100644 frontend/src/interfaces/profile.ts diff --git a/frontend/src/component/admin/groups/Group/Group.tsx b/frontend/src/component/admin/groups/Group/Group.tsx index 2b546570e0..a38360cd51 100644 --- a/frontend/src/component/admin/groups/Group/Group.tsx +++ b/frontend/src/component/admin/groups/Group/Group.tsx @@ -115,7 +115,11 @@ export const Group: VFC = () => { Header: 'Last login', accessor: (row: IGroupUser) => row.seenAt || '', Cell: ({ row: { original: user } }: any) => ( - + `Last login: ${date}`} + /> ), sortType: 'date', maxWidth: 150, diff --git a/frontend/src/component/admin/users/UsersList/UsersList.tsx b/frontend/src/component/admin/users/UsersList/UsersList.tsx index 1e488ab5ff..78f3e89f1c 100644 --- a/frontend/src/component/admin/users/UsersList/UsersList.tsx +++ b/frontend/src/component/admin/users/UsersList/UsersList.tsx @@ -152,7 +152,11 @@ const UsersList = () => { Header: 'Last login', accessor: (row: any) => row.seenAt || '', Cell: ({ row: { original: user } }: any) => ( - + `Last login: ${date}`} + /> ), disableGlobalFilter: true, sortType: 'date', diff --git a/frontend/src/component/common/Badge/Badge.tsx b/frontend/src/component/common/Badge/Badge.tsx index b944542e25..34556cdd07 100644 --- a/frontend/src/component/common/Badge/Badge.tsx +++ b/frontend/src/component/common/Badge/Badge.tsx @@ -14,6 +14,7 @@ type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral'; interface IBadgeProps { color?: Color; icon?: ReactElement; + iconRight?: boolean; className?: string; sx?: SxProps; children?: ReactNode; @@ -23,6 +24,7 @@ interface IBadgeProps { interface IBadgeIconProps { color?: Color; + iconRight?: boolean; } const StyledBadge = styled('div')( @@ -41,18 +43,35 @@ const StyledBadge = styled('div')( ); const StyledBadgeIcon = styled('div')( - ({ 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) => ( + + + cloneElement(icon!, { + sx: { fontSize: '16px' }, + }) + } + /> + +); + export const Badge: FC = forwardRef( ( { color = 'neutral', icon, + iconRight, className, sx, children, @@ -69,22 +88,14 @@ export const Badge: FC = forwardRef( ref={ref} > - - cloneElement(icon!, { - sx: { fontSize: '16px' }, - }) - } - /> - - } + condition={Boolean(icon) && !Boolean(iconRight)} + show={BadgeIcon(color, icon!)} /> {children} + ) ); diff --git a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx index 4f598bd2a1..f1f659a196 100644 --- a/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx +++ b/frontend/src/component/common/BreadcrumbNav/BreadcrumbNav.tsx @@ -26,7 +26,8 @@ const BreadcrumbNav = () => { item !== 'features' && item !== 'features2' && item !== 'create-toggle' && - item !== 'settings' + item !== 'settings' && + item !== 'profile' ); return ( diff --git a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx index 484974e42d..22ff09e6bf 100644 --- a/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx +++ b/frontend/src/component/common/Table/cells/TimeAgoCell/TimeAgoCell.tsx @@ -9,26 +9,24 @@ interface ITimeAgoCellProps { value?: string | number | Date; live?: boolean; emptyText?: string; + title?: (date: string) => string; } export const TimeAgoCell: VFC = ({ value, live = false, emptyText, + title, }) => { const { locationSettings } = useLocationSettings(); if (!value) return {emptyText}; + const date = formatDateYMD(value, locationSettings.locale); + return ( - + diff --git a/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx new file mode 100644 index 0000000000..aae80a5f9c --- /dev/null +++ b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx @@ -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) => ( + + {label} + +); diff --git a/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx new file mode 100644 index 0000000000..f321812748 --- /dev/null +++ b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx @@ -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) => ( + + + {tabs + .filter(tab => !tab.hidden) + .map(tab => ( + onChange(tab)} + /> + ))} + + {children} + +); diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 5d1d1de41a..1668b92402 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -490,5 +490,12 @@ exports[`returns all baseRoutes 1`] = ` "title": "Admin", "type": "protected", }, + { + "component": [Function], + "menu": {}, + "path": "/profile/*", + "title": "Profile", + "type": "protected", + }, ] `; diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 237c88f0f4..77c87ae6c0 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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 */ { diff --git a/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx b/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx index 9c2e5d2579..e58073cc3c 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectGroupView/ProjectGroupView.tsx @@ -89,7 +89,11 @@ const columns = [ Header: 'Last login', accessor: (row: IGroupUser) => row.seenAt || '', Cell: ({ row: { original: user } }: any) => ( - + `Last login: ${date}`} + /> ), sortType: 'date', maxWidth: 150, diff --git a/frontend/src/component/user/UserProfile/EditProfile/EditProfile.tsx b/frontend/src/component/user/Profile/PasswordTab/PasswordTab.tsx similarity index 50% rename from frontend/src/component/user/UserProfile/EditProfile/EditProfile.tsx rename to frontend/src/component/user/Profile/PasswordTab/PasswordTab.tsx index ab2bb2e5b6..68874dd79a 100644 --- a/frontend/src/component/user/UserProfile/EditProfile/EditProfile.tsx +++ b/frontend/src/component/user/Profile/PasswordTab/PasswordTab.tsx @@ -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>; - setUpdatedPassword: React.Dispatch>; -} +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(); 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 ( -
- - Update password - -
+ + Save - - -
+ + ); }; - -export default EditProfile; diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx new file mode 100644 index 0000000000..3ada8ef097 --- /dev/null +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx @@ -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>; + newToken: (token: IPersonalAPIToken) => void; +} + +export const CreatePersonalAPIToken: FC = ({ + 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['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) => { + 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 ( + { + setOpen(false); + }} + label="Create personal API token" + > + + +
+ + Describe what this token will be used for + + setDescription(e.target.value)} + required + /> + + Token expiration date + + + + setExpiration( + e.target.value as ExpirationOption + ) + } + options={expirationOptions} + /> + ( + + Token will expire on{' '} + + {formatDateYMD( + expiresAt!, + locationSettings.locale + )} + + + )} + /> + +
+ + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/DeletePersonalAPIToken/DeletePersonalAPIToken.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/DeletePersonalAPIToken/DeletePersonalAPIToken.tsx new file mode 100644 index 0000000000..c5f0be86b7 --- /dev/null +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/DeletePersonalAPIToken/DeletePersonalAPIToken.tsx @@ -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>; + token?: IPersonalAPIToken; +} + +export const DeletePersonalAPIToken: FC = ({ + 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 ( + { + setOpen(false); + }} + title="Delete token?" + > + + Any applications or scripts using this token " + {token?.description}" will no longer be able to + access the Unleash API. You cannot undo this action. + + + ); +}; diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokenDialog/PersonalAPITokenDialog.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokenDialog/PersonalAPITokenDialog.tsx new file mode 100644 index 0000000000..468b4e3c15 --- /dev/null +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokenDialog/PersonalAPITokenDialog.tsx @@ -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>; + token?: IPersonalAPIToken; +} + +export const PersonalAPITokenDialog: FC = ({ + open, + setOpen, + token, +}) => ( + { + if (!muiCloseReason) { + setOpen(false); + } + }} + title="Personal API token created" + > + + Make sure to copy your personal API token now. You won't be able to + see it again! + + Your token: + + +); diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx new file mode 100644 index 0000000000..825420a40a --- /dev/null +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx @@ -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 = { 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(); + + 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) => ( + + + + { + setSelectedToken(rowToken); + setDeleteOpen(true); + }} + > + + + + + + ), + 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 ( + + + + + + } + /> + + + } + > + + } + /> + + } + > + + Use personal API tokens to authenticate to the Unleash API as + yourself. A personal API token has the same access privileges as + your user. + + + + + 0} + show={ + + No tokens found matching “ + {searchValue} + ” + + } + elseShow={ + + + You have no personal API tokens yet. + + + Need an API token for scripts or testing? + Create a personal API token for quick access + to the Unleash API. + + + + } + /> + } + /> + { + setSelectedToken(token); + setDialogOpen(true); + }} + /> + + + + ); +}; diff --git a/frontend/src/component/user/Profile/Profile.tsx b/frontend/src/component/user/Profile/Profile.tsx new file mode 100644 index 0000000000..44e814fb0d --- /dev/null +++ b/frontend/src/component/user/Profile/Profile.tsx @@ -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 ( + + } + /> + } + /> + } + /> + + ); +}; diff --git a/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx new file mode 100644 index 0000000000..3825d2a386 --- /dev/null +++ b/frontend/src/component/user/Profile/ProfileTab/ProfileTab.tsx @@ -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(); + + 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 ( + <> + + + + + {user.name || user.username} + + {user.email} + + + + Access + + + Your root role + + } + iconRight + > + {profile?.rootRole.name} + + + + + Projects + ( + + { + e.preventDefault(); + navigate(`/projects/${project}`); + }} + color="secondary" + icon={} + > + {project} + + + ))} + elseShow={ + + No projects + + } + /> + + + + Settings + + This is the format used across the system for time and date + + + + Date/Time formatting + + + + + + ); +}; diff --git a/frontend/src/component/user/UserProfile/EditProfile/EditProfile.styles.ts b/frontend/src/component/user/UserProfile/EditProfile/EditProfile.styles.ts deleted file mode 100644 index 031dc5d217..0000000000 --- a/frontend/src/component/user/UserProfile/EditProfile/EditProfile.styles.ts +++ /dev/null @@ -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', - }, - }, -})); diff --git a/frontend/src/component/user/UserProfile/UserProfile.tsx b/frontend/src/component/user/UserProfile/UserProfile.tsx index 7a525586fb..88e5d0d968 100644 --- a/frontend/src/component/user/UserProfile/UserProfile.tsx +++ b/frontend/src/component/user/UserProfile/UserProfile.tsx @@ -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 - >; } -const UserProfile = ({ - profile, - locationSettings, - setLocationSettings, -}: IUserProfileProps) => { +const UserProfile = ({ profile }: IUserProfileProps) => { const [showProfile, setShowProfile] = useState(false); - const [currentLocale, setCurrentLocale] = useState(); 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 ( setShowProfile(false)}>
@@ -74,10 +42,8 @@ const UserProfile = ({ color="secondary" disableRipple > - @@ -85,12 +51,8 @@ const UserProfile = ({
diff --git a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.styles.ts b/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.styles.ts deleted file mode 100644 index 545dafd8f8..0000000000 --- a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.styles.ts +++ /dev/null @@ -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%', - }, -})); diff --git a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx b/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx index ddfab43182..f65c6cc526 100644 --- a/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx +++ b/frontend/src/component/user/UserProfile/UserProfileContent/UserProfileContent.tsx @@ -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)(({ 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 - >; } -const UserProfileContent = ({ +export const UserProfileContent = ({ id, showProfile, + setShowProfile, profile, - possibleLocales, - imageUrl, - currentLocale, - setCurrentLocale, - setLocationSettings, -}: IUserProfileContentProps) => { - const { classes: themeStyles } = useThemeStyles(); - const theme = useTheme(); +}: IUserProfileContentProps) => ( + + + +
+ + {profile.name || profile.username} + + + {profile.email} + +
+
- 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 ( - setShowProfile(false)} > - - - {profile?.email} - - setUpdatedPassword(false)}> - Successfully updated password. - - } - /> - - - setEditingProfile(true) - } - > - Update password - - } - /> -
-
- - - Date/Time formatting - - - -
-
- - Privacy policy - -
+ View profile settings + - - - } - elseShow={ - - } - /> - - } - /> - ); -}; + -export default UserProfileContent; + + Privacy Policy + + + + + + Logout + + + } + /> +); diff --git a/frontend/src/component/user/UserProfile/index.tsx b/frontend/src/component/user/UserProfile/index.tsx index 6ace1538b9..1c662f89b6 100644 --- a/frontend/src/component/user/UserProfile/index.tsx +++ b/frontend/src/component/user/UserProfile/index.tsx @@ -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 ( - - ); + return ; }; export default UserProfileContainer; diff --git a/frontend/src/hooks/api/actions/usePasswordApi/usePasswordApi.ts b/frontend/src/hooks/api/actions/usePasswordApi/usePasswordApi.ts new file mode 100644 index 0000000000..29d23f5f2a --- /dev/null +++ b/frontend/src/hooks/api/actions/usePasswordApi/usePasswordApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts new file mode 100644 index 0000000000..87d97a7a2c --- /dev/null +++ b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts new file mode 100644 index 0000000000..44c60c4dd1 --- /dev/null +++ b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts @@ -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()); +}; diff --git a/frontend/src/hooks/api/getters/useProfile/useProfile.ts b/frontend/src/hooks/api/getters/useProfile/useProfile.ts new file mode 100644 index 0000000000..93f3814eed --- /dev/null +++ b/frontend/src/hooks/api/getters/useProfile/useProfile.ts @@ -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()); +}; diff --git a/frontend/src/interfaces/personalAPIToken.ts b/frontend/src/interfaces/personalAPIToken.ts new file mode 100644 index 0000000000..e221d30faa --- /dev/null +++ b/frontend/src/interfaces/personalAPIToken.ts @@ -0,0 +1,6 @@ +export interface IPersonalAPIToken { + description: string; + expiresAt: string; + createdAt: string; + secret: string; +} diff --git a/frontend/src/interfaces/profile.ts b/frontend/src/interfaces/profile.ts new file mode 100644 index 0000000000..777ce75130 --- /dev/null +++ b/frontend/src/interfaces/profile.ts @@ -0,0 +1,6 @@ +import IRole from './role'; + +export interface IProfile { + rootRole: IRole; + projects: string[]; +} diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 8cf52c0a64..b70971135f 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -41,6 +41,7 @@ export interface IFlags { ENABLE_DARK_MODE_SUPPORT?: boolean; embedProxyFrontend?: boolean; publicSignup?: boolean; + personalAccessTokens?: boolean; } export interface IVersionInfo { diff --git a/frontend/src/themes/dark-theme.ts b/frontend/src/themes/dark-theme.ts index 520675c00c..030ca0a464 100644 --- a/frontend/src/themes/dark-theme.ts +++ b/frontend/src/themes/dark-theme.ts @@ -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', diff --git a/frontend/src/themes/theme.ts b/frontend/src/themes/theme.ts index d8c077a27a..45e4b570a9 100644 --- a/frontend/src/themes/theme.ts +++ b/frontend/src/themes/theme.ts @@ -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', diff --git a/frontend/src/themes/themeTypes.ts b/frontend/src/themes/themeTypes.ts index 367be4cb73..c4bc46c516 100644 --- a/frontend/src/themes/themeTypes.ts +++ b/frontend/src/themes/themeTypes.ts @@ -25,6 +25,8 @@ declare module '@mui/material/styles' { main: string; card: string; elevated: string; + popup: string; + primaryHeader: string; }; } diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index ac78de9bfa..90f2629bd2 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -42,6 +42,7 @@ export interface IExperimentalOptions { embedProxyFrontend?: boolean; batchMetrics?: boolean; anonymiseEventLog?: boolean; + personalAccessTokens?: boolean; }; externalResolver: IExternalFlagResolver; } diff --git a/src/server-dev.ts b/src/server-dev.ts index 696e8d5ca8..5a4fb02d18 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -38,6 +38,7 @@ process.nextTick(async () => { batchMetrics: true, anonymiseEventLog: false, responseTimeWithAppName: true, + personalAccessTokens: true, }, }, authentication: {