Skip to content

Commit

Permalink
Merge pull request #1276 from gchq/feature/update-token-actions-ui
Browse files Browse the repository at this point in the history
Updated token actions input to an autocomplete
  • Loading branch information
ARADDCC012 authored May 21, 2024
2 parents 1f26638 + a778cb8 commit 239cdf8
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 70 deletions.
4 changes: 2 additions & 2 deletions frontend/actions/user.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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',
Expand Down
8 changes: 2 additions & 6 deletions frontend/cypress/e2e/bailo/push-image-registry.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 })
Expand All @@ -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)
Expand Down
167 changes: 126 additions & 41 deletions frontend/pages/settings/personal-access-tokens/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Record<string, TokenActionKeys>[]>(
([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<EntrySearchResult[]>([])
const [selectedActions, setSelectedActions] = useState<TokenActionsKeys[]>([])
const [isAllActions, setIsAllActions] = useState(true)
const [selectedActions, setSelectedActions] = useState<TokenActionKeys[]>(actionOptions)
const [isLoading, setIsLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [token, setToken] = useState<TokenInterface | undefined>()
Expand All @@ -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) => (
<Grid item xs={6} key={action}>
<FormControl>
<FormControlLabel
control={
<Checkbox
name={action}
checked={selectedActions.includes(action)}
onChange={(_event, checked) => handleSelectedActionsChange(action, checked)}
data-test={`${action.replace(':', '')}ActionCheckbox`}
/>
}
label={action}
/>
</FormControl>
</Grid>
)),
[handleSelectedActionsChange, selectedActions],
return (
<Chip
label={isRequired ? `${option} (required)` : option}
size='small'
{...getTagProps({ index })}
{...overrideProps}
key={option}
/>
)
}),
[selectedActions],
)

const handleDescriptionChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
Expand All @@ -95,10 +122,42 @@ export default function NewToken() {
setSelectedModels([])
}

const handleSelectedModelsChange = (_: SyntheticEvent<Element, Event>, value: EntrySearchResult[]) => {
const handleSelectedModelsChange = (_event: SyntheticEvent<Element, Event>, value: EntrySearchResult[]) => {
setSelectedModels(value)
}

const handleAllActionsChange = (_event: ChangeEvent<HTMLInputElement>, checked: boolean) => {
setIsAllActions(checked)
setSelectedActions(checked ? actionOptions : [TokenAction.MODEL_READ])
}

const handleSelectedActionsChange = (
_event: SyntheticEvent<Element, Event>,
value: TokenActionKeys[],
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<TokenActionKeys>,
) => {
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
Expand Down Expand Up @@ -181,9 +240,35 @@ export default function NewToken() {
</Stack>
<Stack>
<Typography fontWeight='bold'>
Actions <span style={{ color: theme.palette.error.main }}>*</span>
Permissions <span style={{ color: theme.palette.error.main }}>*</span>
</Typography>
<Grid container>{actionCheckboxes}</Grid>
<Stack direction='row' alignItems='start' justifyContent='center' spacing={2}>
<FormControl>
<FormControlLabel
control={
<Checkbox
name='All'
checked={isAllActions}
onChange={handleAllActionsChange}
data-test='allActionsCheckbox'
/>
}
label='All'
/>
</FormControl>
<Autocomplete
multiple
fullWidth
value={selectedActions}
options={actionOptions}
limitTags={3}
disableClearable={selectedActions.length === 1}
getLimitTagsText={(more) => `+${plural(more, 'action')}`}
onChange={handleSelectedActionsChange}
renderInput={(params) => <TextField {...params} required size='small' />}
renderTags={renderActionTags}
/>
</Stack>
</Stack>
<Stack alignItems='flex-end'>
<LoadingButton
Expand Down
18 changes: 2 additions & 16 deletions frontend/src/settings/authentication/TokenDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,40 +33,26 @@ type TokenDialogProps = {
}

export default function TokenDialog({ token }: TokenDialogProps) {
const [open, setOpen] = useState(false)
const router = useRouter()
const { tab } = router.query
const [tokenCategory, setTokenCategory] = useState<TokenCategoryKeys>(TokenCategory.PERSONAL_ACCESS)

useEffect(() => {
if (token) setOpen(true)
}, [token])

useEffect(() => {
if (isTokenCategory(tab)) {
setTokenCategory(tab ?? TokenCategory.PERSONAL_ACCESS)
}
}, [tab, setTokenCategory])

const handleClose = () => {
setOpen(false)
router.push('/settings?tab=authentication')
}

const handleListItemClick = (category: TokenCategoryKeys) => {
setTokenCategory(category)
}

return (
<Dialog
fullWidth
disableEscapeKeyDown
maxWidth='xl'
open={open}
onClose={(_event, reason) => {
if (reason !== 'backdropClick') setOpen(false)
}}
PaperProps={{ sx: { height: '90vh' } }}
>
<Dialog fullWidth disableEscapeKeyDown maxWidth='xl' open={!!token} PaperProps={{ sx: { height: '90vh' } }}>
<DialogTitle>Token Created</DialogTitle>
<DialogContent>
<Stack
Expand Down
35 changes: 30 additions & 5 deletions frontend/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,37 @@ export const TokenScope = {

export type TokenScopeKeys = (typeof TokenScope)[keyof typeof TokenScope]

export const TokenActions = {
ImageRead: 'image:read',
FileRead: 'file:read',
export const TokenActionKind = {
READ: 'read',
WRITE: 'write',
}

export type TokenActionKindKeys = (typeof TokenActionKind)[keyof typeof TokenActionKind]

export const TokenAction = {
MODEL_READ: 'model:read',
MODEL_WRITE: 'model:write',

RELEASE_READ: 'release:read',
RELEASE_WRITE: 'release:write',

ACCESS_REQUEST_READ: 'access_request:read',
ACCESS_REQUEST_WRITE: 'access_request:write',

FILE_READ: 'file:read',
FILE_WRITE: 'file:write',

IMAGE_READ: 'image:read',
IMAGE_WRITE: 'image:write',

SCHEMA_READ: 'schema:read',
SCHEMA_WRITE: 'schema:write',

TOKEN_READ: 'token:read',
TOKEN_WRITE: 'token:write',
} as const

export type TokenActionsKeys = (typeof TokenActions)[keyof typeof TokenActions]
export type TokenActionKeys = (typeof TokenAction)[keyof typeof TokenAction]

export const TokenCategory = {
PERSONAL_ACCESS: 'personal access',
Expand Down Expand Up @@ -210,7 +235,7 @@ export interface TokenInterface {
description: string
scope: TokenScopeKeys
modelIds: Array<string>
actions: Array<TokenActionsKeys>
actions: Array<TokenActionKeys>
accessKey: string
secretKey: string
deleted: boolean
Expand Down

0 comments on commit 239cdf8

Please sign in to comment.