From 016892ac0bf9d46844ca82a04f2d4af2782e129c Mon Sep 17 00:00:00 2001 From: ARADDCC012 <110473008+ARADDCC012@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:33:39 +0000 Subject: [PATCH 1/6] Added V2 settings page --- .../e2e/beta/push-image-registry.cy.ts | 2 +- frontend/pages/beta/settings/index.tsx | 28 +++++++++++++++++++ frontend/src/common/PageWithTabs.tsx | 10 +++++-- .../beta/registry/UploadNewImageDialog.tsx | 4 +-- frontend/src/settings/beta/ProfileTab.tsx | 16 +++++++++++ frontend/src/wrapper/TopNavigation.tsx | 10 ++++++- 6 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 frontend/pages/beta/settings/index.tsx create mode 100644 frontend/src/settings/beta/ProfileTab.tsx diff --git a/frontend/cypress/e2e/beta/push-image-registry.cy.ts b/frontend/cypress/e2e/beta/push-image-registry.cy.ts index 1d05832ac..7520ab6ed 100644 --- a/frontend/cypress/e2e/beta/push-image-registry.cy.ts +++ b/frontend/cypress/e2e/beta/push-image-registry.cy.ts @@ -39,7 +39,7 @@ describe('Make and approve an access request', () => { cy.contains('Pushing an Image for this Model') cy.log('Fetching the docker login password and running all the docker commands to push an image') - cy.get('[data-test=showTokenButton]').click() + cy.get('[data-test=regenerateTokenButton]').click() cy.get('[data-test=dockerPassword]').should('not.contain.text', 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx') cy.get('[data-test=dockerPassword]') .invoke('text') diff --git a/frontend/pages/beta/settings/index.tsx b/frontend/pages/beta/settings/index.tsx new file mode 100644 index 000000000..3263a9be6 --- /dev/null +++ b/frontend/pages/beta/settings/index.tsx @@ -0,0 +1,28 @@ +import { useGetCurrentUser } from 'actions/user' +import { useMemo } from 'react' +import Loading from 'src/common/Loading' +import PageWithTabs from 'src/common/PageWithTabs' +import MultipleErrorWrapper from 'src/errors/MultipleErrorWrapper' +import ProfileTab from 'src/settings/beta/ProfileTab' +import Wrapper from 'src/Wrapper.beta' + +export default function Settings() { + const { currentUser, isCurrentUserLoading, isCurrentUserError } = useGetCurrentUser() + + const tabs = useMemo( + () => (currentUser ? [{ title: 'Profile', path: 'profile', view: }] : []), + [currentUser], + ) + + const error = MultipleErrorWrapper(`Unable to load settings page`, { + isCurrentUserError, + }) + if (error) return error + + return ( + + {isCurrentUserLoading && } + + + ) +} diff --git a/frontend/src/common/PageWithTabs.tsx b/frontend/src/common/PageWithTabs.tsx index 23a855138..b9b2e4d99 100644 --- a/frontend/src/common/PageWithTabs.tsx +++ b/frontend/src/common/PageWithTabs.tsx @@ -3,7 +3,7 @@ import { grey } from '@mui/material/colors/' import { useTheme } from '@mui/material/styles' import { useRouter } from 'next/router' import { ParsedUrlQuery } from 'querystring' -import { ReactElement, SyntheticEvent, useContext, useState } from 'react' +import { ReactElement, SyntheticEvent, useContext, useEffect, useState } from 'react' import UnsavedChangesContext from 'src/contexts/unsavedChangesContext' export interface PageTab { @@ -31,7 +31,13 @@ export default function PageWithTabs({ }) { const router = useRouter() const { tab } = router.query - const [currentTab, setCurrentTab] = useState(tabs.find((pageTab) => pageTab.path === tab) ? `${tab}` : tabs[0].path) + + const [currentTab, setCurrentTab] = useState('') + + useEffect(() => { + if (!tabs.length) return + setCurrentTab(tabs.find((pageTab) => pageTab.path === tab) ? `${tab}` : tabs[0].path) + }, [tab, tabs]) const { unsavedChanges, setUnsavedChanges, sendWarning } = useContext(UnsavedChangesContext) diff --git a/frontend/src/model/beta/registry/UploadNewImageDialog.tsx b/frontend/src/model/beta/registry/UploadNewImageDialog.tsx index c84e510a6..b1ffe2e88 100644 --- a/frontend/src/model/beta/registry/UploadNewImageDialog.tsx +++ b/frontend/src/model/beta/registry/UploadNewImageDialog.tsx @@ -55,7 +55,7 @@ export default function UploadModelImageDialog({ open, handleClose, model }: Upl const body = await res.json() token = body.token } catch (error) { - setTokenErrorText('Recieved invalid response from server.') + setTokenErrorText('Received invalid response from server.') } return token @@ -94,7 +94,7 @@ export default function UploadModelImageDialog({ open, handleClose, model }: Upl User authentication token Use the token below to authenticate when you try and run the docker login command - + Name + {user.dn} + + ) +} diff --git a/frontend/src/wrapper/TopNavigation.tsx b/frontend/src/wrapper/TopNavigation.tsx index 6a7e03b6a..742380961 100644 --- a/frontend/src/wrapper/TopNavigation.tsx +++ b/frontend/src/wrapper/TopNavigation.tsx @@ -1,4 +1,4 @@ -import { Add } from '@mui/icons-material' +import { Add, Settings } from '@mui/icons-material' import DarkModeIcon from '@mui/icons-material/DarkMode' import LogoutIcon from '@mui/icons-material/Logout' import MenuIcon from '@mui/icons-material/Menu' @@ -162,6 +162,14 @@ export default function TopNavigation({ + + + + + + Settings + + From b5c829b576cfd0115dea41d307ca86a8f4e04db9 Mon Sep 17 00:00:00 2001 From: ARADDCC012 <110473008+ARADDCC012@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:34:51 +0000 Subject: [PATCH 2/6] Fixed breakpoint logic in various places --- frontend/src/Form/beta/EditableFormHeading.tsx | 4 ++-- frontend/src/model/beta/AccessRequests.tsx | 2 +- frontend/src/model/beta/ModelImages.tsx | 2 +- frontend/src/model/beta/Releases.tsx | 2 +- frontend/src/model/beta/overview/FormEditPage.tsx | 4 ++-- frontend/src/model/beta/overview/TemplatePage.tsx | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/Form/beta/EditableFormHeading.tsx b/frontend/src/Form/beta/EditableFormHeading.tsx index 0ae025218..4b1850e71 100644 --- a/frontend/src/Form/beta/EditableFormHeading.tsx +++ b/frontend/src/Form/beta/EditableFormHeading.tsx @@ -27,8 +27,8 @@ export default function EditableFormHeading({ return ( diff --git a/frontend/src/model/beta/AccessRequests.tsx b/frontend/src/model/beta/AccessRequests.tsx index 6255e96a1..06a25460a 100644 --- a/frontend/src/model/beta/AccessRequests.tsx +++ b/frontend/src/model/beta/AccessRequests.tsx @@ -36,7 +36,7 @@ export default function AccessRequests({ model }: AccessRequestsProps) { } return ( - + diff --git a/frontend/src/model/beta/ModelImages.tsx b/frontend/src/model/beta/ModelImages.tsx index d6b31aee7..1f948aa96 100644 --- a/frontend/src/model/beta/ModelImages.tsx +++ b/frontend/src/model/beta/ModelImages.tsx @@ -48,7 +48,7 @@ export default function ModelImages({ model }: AccessRequestsProps) { return ( <> {isModelImagesLoading && } - + + + + + + Description * + + + + + + Models * + + {isModelsLoading ? ( + + ) : ( + <> + + } + label='All' + /> + + {modelCheckboxes} + + )} + + + + Actions * + + {actionCheckboxes} + + + + Generate Token + + + + + + + + + + ) +} diff --git a/frontend/src/settings/beta/authentication/AuthenticationTab.tsx b/frontend/src/settings/beta/authentication/AuthenticationTab.tsx new file mode 100644 index 000000000..e1df69f8c --- /dev/null +++ b/frontend/src/settings/beta/authentication/AuthenticationTab.tsx @@ -0,0 +1,49 @@ +import { List, ListItem, ListItemButton } from '@mui/material' +import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' +import Stack from '@mui/material/Stack' +import { useRouter } from 'next/router' +import { useState } from 'react' +import DockerAuthentication from 'src/settings/beta/authentication/DockerAuthentication' +import PersonalAuthentication from 'src/settings/beta/authentication/PersonalAuthentication' + +type AuthenticationCategory = 'personal' | 'docker' + +export default function AuthenticationTab() { + const router = useRouter() + const [selectedCategory, setSelectedCategory] = useState( + router.query.category === 'personal' || router.query.category === 'docker' ? router.query.category : 'personal', + ) + + const handleListItemClick = (category: AuthenticationCategory) => { + setSelectedCategory(category) + router.replace({ + query: { ...router.query, category }, + }) + } + + return ( + } + > + + + handleListItemClick('personal')}> + Personal + + + + handleListItemClick('docker')}> + Docker + + + + + {selectedCategory === 'personal' && } + {selectedCategory === 'docker' && } + + + ) +} diff --git a/frontend/src/settings/beta/authentication/DockerAuthentication.tsx b/frontend/src/settings/beta/authentication/DockerAuthentication.tsx new file mode 100644 index 000000000..7fb8ea19a --- /dev/null +++ b/frontend/src/settings/beta/authentication/DockerAuthentication.tsx @@ -0,0 +1,80 @@ +import ContentCopy from '@mui/icons-material/ContentCopy' +import { Box, Button, IconButton, Stack, Tooltip, Typography } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { useState } from 'react' +import MessageAlert from 'src/MessageAlert' +import { getErrorMessage } from 'utils/fetcher' + +export default function DockerAuthentication() { + const theme = useTheme() + const [showToken, setShowToken] = useState(false) + const [token, setToken] = useState('') + const [errorMessage, setErrorMessage] = useState('') + + const regenerateToken = async () => { + const res = await fetch('/api/v1/user/token', { + method: 'POST', + }) + + if (!res.ok) { + setErrorMessage(await getErrorMessage(res)) + return '' + } + + let newToken = '' + + try { + const body = await res.json() + newToken = body.token + } catch (error) { + setErrorMessage('Received invalid response from server.') + } + + return newToken + } + + const handleRegenerateToken = async () => { + const newToken = await regenerateToken() + if (newToken) { + setToken(newToken) + setShowToken(true) + } + } + + const handleRegenerateAndCopy = async () => { + const newToken = await regenerateToken() + if (newToken) { + setToken(newToken) + navigator.clipboard.writeText(newToken) + } + } + + return ( + + + Docker user authentication token + + + + + {showToken ? token : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx'} + + + + + + + + + + ) +} diff --git a/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx b/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx new file mode 100644 index 000000000..d60eadf43 --- /dev/null +++ b/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx @@ -0,0 +1,32 @@ +import { LoadingButton } from '@mui/lab' +import { Box, Stack, Typography } from '@mui/material' +import { useRouter } from 'next/router' +import { useState } from 'react' + +export default function PersonalAuthentication() { + const router = useRouter() + const [isLoading, setIsLoading] = useState(false) + + const handleAddToken = () => { + setIsLoading(true) + router.push('/beta/settings/personal-access-tokens/new') + } + + return ( + + + + Personal Access Tokens + + + Add token + + + + ) +} diff --git a/frontend/src/settings/beta/authentication/TokenDialog.tsx b/frontend/src/settings/beta/authentication/TokenDialog.tsx new file mode 100644 index 000000000..e7d517147 --- /dev/null +++ b/frontend/src/settings/beta/authentication/TokenDialog.tsx @@ -0,0 +1,139 @@ +import { ContentCopy, Visibility, VisibilityOff } from '@mui/icons-material' +import { LoadingButton } from '@mui/lab' +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + IconButton, + Tooltip, + Typography, +} from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import MessageAlert from 'src/MessageAlert' +import { TokenInterface } from 'types/v2/types' + +type TokenDialogProps = { + token?: TokenInterface +} + +export default function TokenDialog({ token }: TokenDialogProps) { + const theme = useTheme() + const router = useRouter() + const [open, setOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [showAccessKey, setShowAccessKey] = useState(false) + const [showSecretKey, setShowSecretKey] = useState(false) + + useEffect(() => { + if (token) setOpen(true) + }, [token]) + + const handleClose = () => { + setIsLoading(true) + router.push('/beta/settings?tab=authentication&category=personal') + } + + const handleCopyAccessKey = () => { + if (token) navigator.clipboard.writeText(token.accessKey) + } + + const handleCopySecretKey = () => { + if (token?.secretKey) navigator.clipboard.writeText(token.secretKey) + } + + const handleToggleAccessKeyVisibility = () => { + setShowAccessKey(!showAccessKey) + } + + const handleToggleSecretKeyVisibility = () => { + setShowSecretKey(!showSecretKey) + } + + return ( + + Token + + + + + Access Key + + + + + {showAccessKey ? token?.accessKey || '' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx'} + + + + + + + + + + + + {showAccessKey ? : } + + + + + Secret Key + + + + + {showSecretKey ? token?.secretKey || '' : 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxx'} + + + + + + + + + + + + {showSecretKey ? : } + + + + + + + + Continue + + + + ) +} diff --git a/frontend/types/v2/types.ts b/frontend/types/v2/types.ts index 5bf32532f..8182b4ccf 100644 --- a/frontend/types/v2/types.ts +++ b/frontend/types/v2/types.ts @@ -94,3 +94,31 @@ export interface EntityObject { kind: string id: string } + +export const TokenScope = { + All: 'all', + Models: 'models', +} as const + +export type TokenScopeKeys = (typeof TokenScope)[keyof typeof TokenScope] + +export const TokenActions = { + ImageRead: 'image:read', + FileRead: 'file:read', +} as const + +export type TokenActionsKeys = (typeof TokenActions)[keyof typeof TokenActions] + +export interface TokenInterface { + user: string + description: string + scope: TokenScopeKeys + modelIds: Array + actions: Array + accessKey: string + secretKey?: string + deleted: boolean + createdAt: Date + updatedAt: Date + compareToken: (candidateToken: string) => Promise +} From dbfc0af120b5fc1dcaf02092aa506c80c871f223 Mon Sep 17 00:00:00 2001 From: ARADDCC012 <110473008+ARADDCC012@users.noreply.github.com> Date: Thu, 4 Jan 2024 16:08:11 +0000 Subject: [PATCH 4/6] Added token list view and delete functionality --- frontend/actions/user.ts | 25 ++++++++- .../authentication/PersonalAuthentication.tsx | 51 ++++++++++++++++++- .../beta/authentication/TokenDialog.tsx | 2 +- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/frontend/actions/user.ts b/frontend/actions/user.ts index 8b905848a..d76fe40fe 100644 --- a/frontend/actions/user.ts +++ b/frontend/actions/user.ts @@ -1,6 +1,6 @@ import qs from 'querystring' import useSWR from 'swr' -import { EntityObject, ModelInterface, TokenActionsKeys, TokenScopeKeys, User } from 'types/v2/types' +import { EntityObject, ModelInterface, TokenActionsKeys, TokenInterface, TokenScopeKeys, User } from 'types/v2/types' import { ErrorInfo, fetcher } from '../utils/fetcher' @@ -32,7 +32,7 @@ interface UserResponse { } export function useGetCurrentUser() { - const { data, error, mutate } = useSWR(`/api/v2/entities/me`, fetcher) + const { data, error, mutate } = useSWR('/api/v2/entities/me', fetcher) return { mutateCurrentUser: mutate, @@ -42,6 +42,21 @@ export function useGetCurrentUser() { } } +interface GetUserTokensResponse { + tokens: TokenInterface[] +} + +export function useGetUserTokens() { + const { data, error, mutate } = useSWR('/api/v2/user/tokens', fetcher) + + return { + mutateTokens: mutate, + tokens: data?.tokens || [], + isTokensLoading: !error && !data, + isTokensError: error, + } +} + export function postUserToken( description: string, scope: TokenScopeKeys, @@ -54,3 +69,9 @@ export function postUserToken( body: JSON.stringify({ description, scope, modelIds, actions }), }) } + +export function deleteUserToken(accessKey: TokenInterface['accessKey']) { + return fetch(`/api/v2/user/token/${accessKey}`, { + method: 'delete', + }) +} diff --git a/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx b/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx index d60eadf43..a01443e36 100644 --- a/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx +++ b/frontend/src/settings/beta/authentication/PersonalAuthentication.tsx @@ -1,11 +1,55 @@ +import { Delete } from '@mui/icons-material' import { LoadingButton } from '@mui/lab' -import { Box, Stack, Typography } from '@mui/material' +import { Box, IconButton, Stack, Tooltip, Typography } from '@mui/material' +import { deleteUserToken, useGetUserTokens } from 'actions/user' import { useRouter } from 'next/router' -import { useState } from 'react' +import { Fragment, useCallback, useMemo, useState } from 'react' +import Loading from 'src/common/Loading' +import MessageAlert from 'src/MessageAlert' +import { TokenInterface } from 'types/v2/types' +import { getErrorMessage } from 'utils/fetcher' export default function PersonalAuthentication() { const router = useRouter() const [isLoading, setIsLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const { tokens, isTokensLoading, isTokensError, mutateTokens } = useGetUserTokens() + + const handleDeleteToken = useCallback( + async (accessKey: TokenInterface['accessKey']) => { + const response = await deleteUserToken(accessKey) + + if (!response.ok) { + setErrorMessage(await getErrorMessage(response)) + } else { + mutateTokens() + } + }, + [mutateTokens], + ) + + const tokenList = useMemo( + () => + tokens.map((token, index) => ( + + + {token.description} + + handleDeleteToken(token.accessKey)} + aria-label='delete access key' + > + + + + + {index !== tokens.length - 1 && } + + )), + [handleDeleteToken, tokens], + ) const handleAddToken = () => { setIsLoading(true) @@ -27,6 +71,9 @@ export default function PersonalAuthentication() { Add token + + {isTokensLoading && } + {tokenList} ) } diff --git a/frontend/src/settings/beta/authentication/TokenDialog.tsx b/frontend/src/settings/beta/authentication/TokenDialog.tsx index e7d517147..10b7c5589 100644 --- a/frontend/src/settings/beta/authentication/TokenDialog.tsx +++ b/frontend/src/settings/beta/authentication/TokenDialog.tsx @@ -56,7 +56,7 @@ export default function TokenDialog({ token }: TokenDialogProps) { return ( - Token + Token Created Date: Thu, 4 Jan 2024 17:32:20 +0000 Subject: [PATCH 5/6] POST with empty modelIds array if all models selected --- frontend/pages/beta/settings/personal-access-tokens/new.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/pages/beta/settings/personal-access-tokens/new.tsx b/frontend/pages/beta/settings/personal-access-tokens/new.tsx index 4a9afafa7..ec494eeb3 100644 --- a/frontend/pages/beta/settings/personal-access-tokens/new.tsx +++ b/frontend/pages/beta/settings/personal-access-tokens/new.tsx @@ -129,7 +129,8 @@ export default function NewToken() { const handleSubmit = async () => { setIsLoading(true) const scope = isAllModels ? TokenScope.All : TokenScope.Models - const response = await postUserToken(description, scope, selectedModels, selectedActions) + const modelIds = isAllModels ? [] : selectedModels + const response = await postUserToken(description, scope, modelIds, selectedActions) if (!response.ok) { setErrorMessage(await getErrorMessage(response)) From a6e0a8c521b686cba7a78588041fc19d62e1b2c0 Mon Sep 17 00:00:00 2001 From: ARADDCC012 <110473008+ARADDCC012@users.noreply.github.com> Date: Fri, 5 Jan 2024 10:16:58 +0000 Subject: [PATCH 6/6] Replaced hard-coded colours with theme colours and set max size for token description --- frontend/pages/beta/schemas/new.tsx | 10 ++++++---- .../pages/beta/settings/personal-access-tokens/new.tsx | 9 ++++++--- frontend/src/MuiForms/RichTextInput.tsx | 2 +- frontend/src/model/beta/common/ValidationErrorIcon.tsx | 5 ++++- frontend/src/model/beta/releases/ReleaseForm.tsx | 7 +++++-- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/frontend/pages/beta/schemas/new.tsx b/frontend/pages/beta/schemas/new.tsx index 602a0bc6e..33c794a21 100644 --- a/frontend/pages/beta/schemas/new.tsx +++ b/frontend/pages/beta/schemas/new.tsx @@ -2,6 +2,7 @@ import { ArrowBack, Schema } from '@mui/icons-material' import { LoadingButton } from '@mui/lab' import { Box, Button, Container, MenuItem, Paper, Select, Stack, TextField, Typography } from '@mui/material' import { styled } from '@mui/material/styles' +import { useTheme } from '@mui/material/styles' import { postSchema, SchemaKind } from 'actions/schema' import { useRouter } from 'next/router' import { ChangeEvent, FormEvent, useState } from 'react' @@ -34,6 +35,7 @@ export default function NewSchema() { const [loading, setLoading] = useState(false) const router = useRouter() + const theme = useTheme() const handleUploadChange = (event: ChangeEvent) => { const fileReader = new FileReader() @@ -106,7 +108,7 @@ export default function NewSchema() { - Id * + Id * - Name * + Name * - Description * + Description * - Schema Type * + Schema Type *