Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updated token actions input to an autocomplete #1276

Merged
merged 6 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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') {
ARADDCC012 marked this conversation as resolved.
Show resolved Hide resolved
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)) {
ARADDCC012 marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -175,12 +175,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 @@ -209,7 +234,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
Loading