diff --git a/frontend/actions/user.ts b/frontend/actions/user.ts index d8f881ab4..0762e4f65 100644 --- a/frontend/actions/user.ts +++ b/frontend/actions/user.ts @@ -1,7 +1,7 @@ import qs from 'querystring' import { UserInformation } from 'src/common/UserDisplay' import useSWR from 'swr' -import { EntityObject, EntryInterface, TokenActionsKeys, TokenInterface, TokenScopeKeys, User } from 'types/types' +import { EntityObject, EntryInterface, TokenActionKeys, TokenInterface, TokenScopeKeys, User } from 'types/types' import { ErrorInfo, fetcher } from '../utils/fetcher' @@ -77,7 +77,7 @@ export function postUserToken( description: string, scope: TokenScopeKeys, modelIds: EntryInterface['id'][], - actions: TokenActionsKeys[], + actions: TokenActionKeys[], ) { return fetch('/api/v2/user/tokens', { method: 'post', diff --git a/frontend/cypress/e2e/bailo/push-image-registry.cy.ts b/frontend/cypress/e2e/bailo/push-image-registry.cy.ts index 3c5fc9b5d..2490504c0 100644 --- a/frontend/cypress/e2e/bailo/push-image-registry.cy.ts +++ b/frontend/cypress/e2e/bailo/push-image-registry.cy.ts @@ -31,9 +31,6 @@ describe('Make and approve an access request', () => { cy.log('Navigating to token generation page') cy.visit(`/settings/personal-access-tokens/new`) cy.get('[data-test=tokenDescriptionTextField]').type('This token works for all models') - cy.get('[data-test=allModelsCheckbox]').click() - cy.get('[data-test=imagereadActionCheckbox]').click() - cy.get('[data-test=filereadActionCheckbox]').click() cy.get('[data-test=generatePersonalAccessTokenButton]').click() cy.log('Saving access key and secret key') @@ -43,8 +40,7 @@ describe('Make and approve an access request', () => { cy.get('[data-test=secretKeyText]').invoke('text').as('secretKey') }) - // Temporarily disabled until token UI changes go in. - it.skip('can push and pull to the registry', function () { + it('can push and pull to the registry', function () { cy.log('Running all the docker commands to push an image') cy.exec(`docker login ${registryUrl} -u ${this.accessKey} -p ${this.secretKey}`, { timeout: 60000 }) cy.exec(`docker build --tag ${testModelImage} cypress/fixtures/docker-image`, { timeout: 60000 }) @@ -54,7 +50,7 @@ describe('Make and approve an access request', () => { cy.exec(`docker push ${registryUrl}/${modelUuidForRegistry}/${testModelImage}:1`, { timeout: 60000 }) }) - it.skip('can select the image when drafting a release', () => { + it('can select the image when drafting a release', () => { cy.log('Navigating to the model page and then to the releases tab') cy.visit(`/model/${modelUuidForRegistry}`) cy.contains(modelNameForRegistry) diff --git a/frontend/pages/settings/personal-access-tokens/new.tsx b/frontend/pages/settings/personal-access-tokens/new.tsx index ac7199c10..6091122cd 100644 --- a/frontend/pages/settings/personal-access-tokens/new.tsx +++ b/frontend/pages/settings/personal-access-tokens/new.tsx @@ -2,13 +2,16 @@ import { ArrowBack } from '@mui/icons-material' import { LoadingButton } from '@mui/lab' import { Autocomplete, + AutocompleteChangeDetails, + AutocompleteChangeReason, + AutocompleteRenderGetTagProps, Button, Card, Checkbox, + Chip, Container, FormControl, FormControlLabel, - Grid, Stack, TextField, Typography, @@ -21,16 +24,49 @@ import Title from 'src/common/Title' import Link from 'src/Link' import MessageAlert from 'src/MessageAlert' import TokenDialog from 'src/settings/authentication/TokenDialog' -import { TokenActions, TokenActionsKeys, TokenInterface, TokenScope } from 'types/types' +import { TokenAction, TokenActionKeys, TokenActionKind, TokenInterface, TokenScope } from 'types/types' import { getErrorMessage } from 'utils/fetcher' import { plural } from 'utils/stringUtils' +const [TokenReadAction, TokenWriteAction] = Object.values(TokenAction).reduce[]>( + ([readActions, writeActions], action) => { + let groupedActions = [readActions, writeActions] + const [name, kind] = action.split(':') + + if (kind === TokenActionKind.READ) { + groupedActions = [{ ...readActions, [name]: action }, writeActions] + } + if (kind === TokenActionKind.WRITE) { + groupedActions = [readActions, { ...writeActions, [name]: action }] + } + + return groupedActions + }, + [{}, {}], +) + +const isWriteAction = (action: TokenActionKeys) => { + return Object.values(TokenWriteAction).includes(action) +} + +const isReadAction = (action: TokenActionKeys) => { + return Object.values(TokenReadAction).includes(action) +} + +const getActionName = (action: TokenActionKeys) => { + const [name, _kind] = action.split(':') + return name +} + +const actionOptions = Object.values(TokenAction) + export default function NewToken() { const theme = useTheme() const [description, setDescription] = useState('') - const [isAllModels, setIsAllModels] = useState(false) + const [isAllModels, setIsAllModels] = useState(true) const [selectedModels, setSelectedModels] = useState([]) - const [selectedActions, setSelectedActions] = useState([]) + const [isAllActions, setIsAllActions] = useState(true) + const [selectedActions, setSelectedActions] = useState(actionOptions) const [isLoading, setIsLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [token, setToken] = useState() @@ -48,42 +84,33 @@ export default function NewToken() { [description, isAllModels, selectedModels.length, selectedActions.length], ) - const handleSelectedActionsChange = useCallback( - (action: TokenActionsKeys, checked: boolean) => { - if (checked) { - setSelectedActions([...selectedActions, action]) - } else { - const foundIndex = selectedActions.findIndex((selectedRepository) => selectedRepository === action) - if (foundIndex >= 0) { - const updatedSelectedActions = [...selectedActions] - updatedSelectedActions.splice(foundIndex, 1) - setSelectedActions(updatedSelectedActions) + const renderActionTags = useCallback( + (value: TokenActionKeys[], getTagProps: AutocompleteRenderGetTagProps) => + value.map((option, index) => { + const actionName = getActionName(option) + const isRequired = + option === TokenAction.MODEL_READ || + (isReadAction(option) && selectedActions.includes(TokenWriteAction[actionName])) + + // overrideProps is used to disable delete functionality for model:read and any selected + // read actions with a corresponding selected write action + const overrideProps = { + ...(isRequired && { + onDelete: undefined, + }), } - } - }, - [selectedActions], - ) - const actionCheckboxes = useMemo( - () => - Object.values(TokenActions).map((action) => ( - - - handleSelectedActionsChange(action, checked)} - data-test={`${action.replace(':', '')}ActionCheckbox`} - /> - } - label={action} - /> - - - )), - [handleSelectedActionsChange, selectedActions], + return ( + + ) + }), + [selectedActions], ) const handleDescriptionChange = (event: ChangeEvent) => { @@ -95,10 +122,42 @@ export default function NewToken() { setSelectedModels([]) } - const handleSelectedModelsChange = (_: SyntheticEvent, value: EntrySearchResult[]) => { + const handleSelectedModelsChange = (_event: SyntheticEvent, value: EntrySearchResult[]) => { setSelectedModels(value) } + const handleAllActionsChange = (_event: ChangeEvent, checked: boolean) => { + setIsAllActions(checked) + setSelectedActions(checked ? actionOptions : [TokenAction.MODEL_READ]) + } + + const handleSelectedActionsChange = ( + _event: SyntheticEvent, + value: TokenActionKeys[], + reason: AutocompleteChangeReason, + details?: AutocompleteChangeDetails, + ) => { + if (reason === 'clear') { + setIsAllActions(false) + setSelectedActions([TokenAction.MODEL_READ]) + return + } + + const updatedValue = [...value] + + // If the selected option is a write action, ensure the corresponding + // read action is also selected + if (reason === 'selectOption' && details && isWriteAction(details.option)) { + const actionName = getActionName(details.option) + if (!updatedValue.includes(TokenReadAction[actionName])) { + updatedValue.push(TokenReadAction[actionName]) + } + } + + setIsAllActions(updatedValue.length === actionOptions.length) + setSelectedActions(updatedValue) + } + const handleSubmit = async () => { setIsLoading(true) const scope = isAllModels ? TokenScope.All : TokenScope.Models @@ -181,9 +240,35 @@ export default function NewToken() { - Actions * + Permissions * - {actionCheckboxes} + + + + } + label='All' + /> + + `+${plural(more, 'action')}`} + onChange={handleSelectedActionsChange} + renderInput={(params) => } + renderTags={renderActionTags} + /> + (TokenCategory.PERSONAL_ACCESS) - useEffect(() => { - if (token) setOpen(true) - }, [token]) - useEffect(() => { if (isTokenCategory(tab)) { setTokenCategory(tab ?? TokenCategory.PERSONAL_ACCESS) @@ -49,7 +44,7 @@ export default function TokenDialog({ token }: TokenDialogProps) { }, [tab, setTokenCategory]) const handleClose = () => { - setOpen(false) + router.push('/settings?tab=authentication') } const handleListItemClick = (category: TokenCategoryKeys) => { @@ -57,16 +52,7 @@ export default function TokenDialog({ token }: TokenDialogProps) { } return ( - { - if (reason !== 'backdropClick') setOpen(false) - }} - PaperProps={{ sx: { height: '90vh' } }} - > + Token Created - actions: Array + actions: Array accessKey: string secretKey: string deleted: boolean