Loading backend @ e3879174 Compare ba2a4a50 to e3879174 Original line number Original line Diff line number Diff line Subproject commit ba2a4a5086a7c021c8181d3f1e43e0d70a16bdbd Subproject commit e3879174c07ab4a24cd3b09fdd7260a85e90d668 codegen/package.json +1 −1 Original line number Original line Diff line number Diff line { { "name": "@inject/codegen", "name": "@inject/codegen", "version": "3.28.1", "version": "3.30.0", "description": "GraphQL API Codegen Setup for the Inject Backend", "description": "GraphQL API Codegen Setup for the Inject Backend", "main": "index.js", "main": "index.js", "license": "MIT", "license": "MIT", Loading frontend/package.json +1 −1 Original line number Original line Diff line number Diff line { { "name": "@inject/frontend", "name": "@inject/frontend", "version": "3.28.1", "version": "3.30.0", "description": "Main wrapper for rendering INJECT Frontend", "description": "Main wrapper for rendering INJECT Frontend", "main": "index.js", "main": "index.js", "license": "MIT", "license": "MIT", Loading frontend/src/clientsettings/components/ChangePassword.tsx +117 −140 Original line number Original line Diff line number Diff line import { import { Button, Button, FormGroup, InputGroup, InputGroup, Label, Section, Section, SectionCard, SectionCard, } from '@blueprintjs/core' } from '@blueprintjs/core' import { css } from '@emotion/css' import { css, cx } from '@emotion/css' import type { PropsOf } from '@emotion/react' import { useHost } from '@inject/graphql' import { ChangePassword, useTypedMutation } from '@inject/graphql' import { import { CenteredSpinner } from '@inject/shared' authenticatedFetch, import type { ChangeEventHandler, FC, FormEventHandler } from 'react' changePasswordUrl, import { useRef } from 'react' ErrorMessage, notifyNoncommmit, setSessionId, useWiggle, wiggleClass, } from '@inject/shared' import type { FormEvent } from 'react' import { useState } from 'react' const passwordInputProps: PropsOf<typeof InputGroup> = { const marginTop = css` required: true, margin-top: 0.25rem; type: 'password', ` placeholder: 'New_password123', inputClassName: css` &:invalid:not(:placeholder-shown), &:user-invalid:not(:placeholder-shown) { border-color: red !important; border: 1px solid; } `, autoComplete: 'new-password', } const ChangePasswordSetting: FC<{ export const ChangePasswordForm = () => { onSubmit: FormEventHandler<HTMLFormElement> const [open, setOpen] = useState(false) }> = ({ onSubmit }) => { const newPasswordRef = useRef<HTMLInputElement>(null) const repeatPasswordRef = useRef<HTMLInputElement>(null) const oldPasswordRef = useRef<HTMLInputElement>(null) const handleNewPasswordChange: ChangeEventHandler<HTMLInputElement> = () => { const [oldPassword, setOldPassword] = useState('') const passwordInput = newPasswordRef.current const [newPassword, setNewPassword] = useState('') if (!passwordInput) return const errors = [] passwordInput.reportValidity() if (passwordInput.validity.tooShort) { errors.push('have at least 8 characters') } if (!/[a-z]/.test(passwordInput.value)) { errors.push('contain at least one lowercase letter') } if (!/[A-Z]/.test(passwordInput.value)) { errors.push('contain at least one uppercase letter') } if (!/[0-9]/.test(passwordInput.value)) { errors.push('contain at least one number') } if (!/[!@#$%^&*_=+-]/.test(passwordInput.value)) { errors.push('at least one of these symbols !@#$%^&*_=+-') } if (errors.length === 0) { passwordInput.setCustomValidity('') } else { passwordInput.setCustomValidity(`Password must ${errors.join(', ')}`) } } const handleRepeatPasswordChange: ChangeEventHandler< const [loading, setLoading] = useState(false) HTMLInputElement const [errors, setErrors] = useState<{ > = event => { oldPassword: string const repeatPasswordInput = repeatPasswordRef.current newPassword: string const newPassword = newPasswordRef.current?.value submit: string }>({ oldPassword: '', newPassword: '', submit: '', }) if (!repeatPasswordInput) return const { wiggling, beginWiggling } = useWiggle() if (event.target.value !== newPassword) { const host = useHost() repeatPasswordInput.setCustomValidity('Passwords do not match.') } else { repeatPasswordInput.setCustomValidity('') } } return ( const handleSubmit = (event: FormEvent<HTMLFormElement>) => { <SectionCard padded> event.preventDefault() <form onSubmit={onSubmit}> setErrors({ oldPassword: '', newPassword: '', submit: '' }) <input id='username' autoComplete='username' hidden /> if (!oldPassword || !newPassword) { <Label htmlFor='oldpassword'> setErrors({ Current password: oldPassword: !oldPassword ? 'Old password is required.' : '', <InputGroup newPassword: !newPassword ? 'New password is required.' : '', id='oldpassword' submit: '', name='oldpassword' }) inputRef={oldPasswordRef} beginWiggling() autoComplete='current-password' return type='password' required /> </Label> <Label htmlFor='newpassword1'> New password: <InputGroup id='newpassword1' name='newpassword1' inputRef={newPasswordRef} onChange={handleNewPasswordChange} {...passwordInputProps} /> </Label> <Label htmlFor='newpassword2'> New password (again): <InputGroup id='newpassword2' name='newpassword2' inputRef={repeatPasswordRef} onChange={handleRepeatPasswordChange} {...passwordInputProps} /> </Label> <Button type='submit' style={{ float: 'right' }}> Change password </Button> </form> <p> New password must be at least 8 characters long and contain uppercase and lowercase letters, number and special symbols (!@#$%^&*_=+-) </p> </SectionCard> ) } } export const ChangePasswordForm = () => { setLoading(true) const [{ data, fetching, error }, mutate] = useTypedMutation(ChangePassword) authenticatedFetch(changePasswordUrl(host || ''), { method: 'POST', const handler: FormEventHandler<HTMLFormElement> = event => { body: JSON.stringify({ event.preventDefault() old_password: oldPassword, const oldPassword = event.currentTarget.querySelector( new_password: newPassword, '#oldpassword' }), ) as HTMLInputElement headers: { const newPassword = event.currentTarget.querySelector( Accept: 'application/json', '#newpassword1' 'Content-Type': 'application/json', ) as HTMLInputElement }, const oldPasswordValue = oldPassword.value }) const newPasswordValue = newPassword.value .then(result => result.json()) if (oldPasswordValue === newPasswordValue) { .then((result: { status: string; detail: string; sessionid: string }) => { newPassword.setCustomValidity('The new password must be different') if (result.status === 'error') { setErrors(prev => ({ ...prev, submit: result.detail })) beginWiggling() return return } } mutate({ setOldPassword('') newPassword: newPasswordValue, setNewPassword('') newPasswordRepeat: newPasswordValue, setSessionId(result.sessionid) oldPassword: oldPasswordValue, setOpen(false) notifyNoncommmit('Password changed successfully.', { intent: 'success', }) }) }) .finally(() => setLoading(false)) } } return ( return ( Loading @@ -151,18 +92,54 @@ export const ChangePasswordForm = () => { title='Change Password' title='Change Password' icon='key' icon='key' collapsible collapsible collapseProps={{ defaultIsOpen: false }} collapseProps={{ isOpen: open, onToggle: () => setOpen(prev => !prev) }} > > {error && ( <SectionCard padded> <SectionCard padded> <p>Error: {error.message}</p> <form onSubmit={handleSubmit}> </SectionCard> <FormGroup )} label='Old Password' {fetching && <CenteredSpinner />} intent={errors.oldPassword ? 'danger' : 'none'} {(!data || !data.passwordChange?.passwordChanged) && ( helperText={errors.oldPassword ? errors.oldPassword : undefined} <ChangePasswordSetting onSubmit={handler} /> > <InputGroup intent={errors.oldPassword ? 'danger' : 'none'} placeholder='Old Password' value={oldPassword} onChange={e => setOldPassword(e.target.value)} type='password' /> </FormGroup> <FormGroup label='New Password' intent={errors.newPassword ? 'danger' : 'none'} helperText={errors.newPassword ? errors.newPassword : undefined} > <InputGroup intent={errors.oldPassword ? 'danger' : 'none'} placeholder='New Password' value={newPassword} onChange={e => setNewPassword(e.target.value)} type='password' /> </FormGroup> {errors.submit && ( <ErrorMessage minimal>{errors.submit}</ErrorMessage> )} )} {data?.passwordChange?.passwordChanged && <p>Password changed</p>} <Button disabled={wiggling} type='submit' intent='primary' className={cx({ [wiggleClass]: wiggling, [marginTop]: !!errors.submit, })} loading={loading} > Submit </Button> </form> </SectionCard> </Section> </Section> ) ) } } frontend/src/components/OnDemandStartAlert/index.tsx +1 −0 Original line number Original line Diff line number Diff line Loading @@ -84,6 +84,7 @@ export const OnDemandStartAlert: FC<OnDemandStartAlertProps> = ({ loading={fetching} loading={fetching} cancelButtonText='Cancel' cancelButtonText='Cancel' onCancel={() => onCancel={() => // TODO: if only one team, navigate to exercise selection nav({ nav({ to: TraineeExerciseRoute.to, to: TraineeExerciseRoute.to, params: { params: { Loading Loading
backend @ e3879174 Compare ba2a4a50 to e3879174 Original line number Original line Diff line number Diff line Subproject commit ba2a4a5086a7c021c8181d3f1e43e0d70a16bdbd Subproject commit e3879174c07ab4a24cd3b09fdd7260a85e90d668
codegen/package.json +1 −1 Original line number Original line Diff line number Diff line { { "name": "@inject/codegen", "name": "@inject/codegen", "version": "3.28.1", "version": "3.30.0", "description": "GraphQL API Codegen Setup for the Inject Backend", "description": "GraphQL API Codegen Setup for the Inject Backend", "main": "index.js", "main": "index.js", "license": "MIT", "license": "MIT", Loading
frontend/package.json +1 −1 Original line number Original line Diff line number Diff line { { "name": "@inject/frontend", "name": "@inject/frontend", "version": "3.28.1", "version": "3.30.0", "description": "Main wrapper for rendering INJECT Frontend", "description": "Main wrapper for rendering INJECT Frontend", "main": "index.js", "main": "index.js", "license": "MIT", "license": "MIT", Loading
frontend/src/clientsettings/components/ChangePassword.tsx +117 −140 Original line number Original line Diff line number Diff line import { import { Button, Button, FormGroup, InputGroup, InputGroup, Label, Section, Section, SectionCard, SectionCard, } from '@blueprintjs/core' } from '@blueprintjs/core' import { css } from '@emotion/css' import { css, cx } from '@emotion/css' import type { PropsOf } from '@emotion/react' import { useHost } from '@inject/graphql' import { ChangePassword, useTypedMutation } from '@inject/graphql' import { import { CenteredSpinner } from '@inject/shared' authenticatedFetch, import type { ChangeEventHandler, FC, FormEventHandler } from 'react' changePasswordUrl, import { useRef } from 'react' ErrorMessage, notifyNoncommmit, setSessionId, useWiggle, wiggleClass, } from '@inject/shared' import type { FormEvent } from 'react' import { useState } from 'react' const passwordInputProps: PropsOf<typeof InputGroup> = { const marginTop = css` required: true, margin-top: 0.25rem; type: 'password', ` placeholder: 'New_password123', inputClassName: css` &:invalid:not(:placeholder-shown), &:user-invalid:not(:placeholder-shown) { border-color: red !important; border: 1px solid; } `, autoComplete: 'new-password', } const ChangePasswordSetting: FC<{ export const ChangePasswordForm = () => { onSubmit: FormEventHandler<HTMLFormElement> const [open, setOpen] = useState(false) }> = ({ onSubmit }) => { const newPasswordRef = useRef<HTMLInputElement>(null) const repeatPasswordRef = useRef<HTMLInputElement>(null) const oldPasswordRef = useRef<HTMLInputElement>(null) const handleNewPasswordChange: ChangeEventHandler<HTMLInputElement> = () => { const [oldPassword, setOldPassword] = useState('') const passwordInput = newPasswordRef.current const [newPassword, setNewPassword] = useState('') if (!passwordInput) return const errors = [] passwordInput.reportValidity() if (passwordInput.validity.tooShort) { errors.push('have at least 8 characters') } if (!/[a-z]/.test(passwordInput.value)) { errors.push('contain at least one lowercase letter') } if (!/[A-Z]/.test(passwordInput.value)) { errors.push('contain at least one uppercase letter') } if (!/[0-9]/.test(passwordInput.value)) { errors.push('contain at least one number') } if (!/[!@#$%^&*_=+-]/.test(passwordInput.value)) { errors.push('at least one of these symbols !@#$%^&*_=+-') } if (errors.length === 0) { passwordInput.setCustomValidity('') } else { passwordInput.setCustomValidity(`Password must ${errors.join(', ')}`) } } const handleRepeatPasswordChange: ChangeEventHandler< const [loading, setLoading] = useState(false) HTMLInputElement const [errors, setErrors] = useState<{ > = event => { oldPassword: string const repeatPasswordInput = repeatPasswordRef.current newPassword: string const newPassword = newPasswordRef.current?.value submit: string }>({ oldPassword: '', newPassword: '', submit: '', }) if (!repeatPasswordInput) return const { wiggling, beginWiggling } = useWiggle() if (event.target.value !== newPassword) { const host = useHost() repeatPasswordInput.setCustomValidity('Passwords do not match.') } else { repeatPasswordInput.setCustomValidity('') } } return ( const handleSubmit = (event: FormEvent<HTMLFormElement>) => { <SectionCard padded> event.preventDefault() <form onSubmit={onSubmit}> setErrors({ oldPassword: '', newPassword: '', submit: '' }) <input id='username' autoComplete='username' hidden /> if (!oldPassword || !newPassword) { <Label htmlFor='oldpassword'> setErrors({ Current password: oldPassword: !oldPassword ? 'Old password is required.' : '', <InputGroup newPassword: !newPassword ? 'New password is required.' : '', id='oldpassword' submit: '', name='oldpassword' }) inputRef={oldPasswordRef} beginWiggling() autoComplete='current-password' return type='password' required /> </Label> <Label htmlFor='newpassword1'> New password: <InputGroup id='newpassword1' name='newpassword1' inputRef={newPasswordRef} onChange={handleNewPasswordChange} {...passwordInputProps} /> </Label> <Label htmlFor='newpassword2'> New password (again): <InputGroup id='newpassword2' name='newpassword2' inputRef={repeatPasswordRef} onChange={handleRepeatPasswordChange} {...passwordInputProps} /> </Label> <Button type='submit' style={{ float: 'right' }}> Change password </Button> </form> <p> New password must be at least 8 characters long and contain uppercase and lowercase letters, number and special symbols (!@#$%^&*_=+-) </p> </SectionCard> ) } } export const ChangePasswordForm = () => { setLoading(true) const [{ data, fetching, error }, mutate] = useTypedMutation(ChangePassword) authenticatedFetch(changePasswordUrl(host || ''), { method: 'POST', const handler: FormEventHandler<HTMLFormElement> = event => { body: JSON.stringify({ event.preventDefault() old_password: oldPassword, const oldPassword = event.currentTarget.querySelector( new_password: newPassword, '#oldpassword' }), ) as HTMLInputElement headers: { const newPassword = event.currentTarget.querySelector( Accept: 'application/json', '#newpassword1' 'Content-Type': 'application/json', ) as HTMLInputElement }, const oldPasswordValue = oldPassword.value }) const newPasswordValue = newPassword.value .then(result => result.json()) if (oldPasswordValue === newPasswordValue) { .then((result: { status: string; detail: string; sessionid: string }) => { newPassword.setCustomValidity('The new password must be different') if (result.status === 'error') { setErrors(prev => ({ ...prev, submit: result.detail })) beginWiggling() return return } } mutate({ setOldPassword('') newPassword: newPasswordValue, setNewPassword('') newPasswordRepeat: newPasswordValue, setSessionId(result.sessionid) oldPassword: oldPasswordValue, setOpen(false) notifyNoncommmit('Password changed successfully.', { intent: 'success', }) }) }) .finally(() => setLoading(false)) } } return ( return ( Loading @@ -151,18 +92,54 @@ export const ChangePasswordForm = () => { title='Change Password' title='Change Password' icon='key' icon='key' collapsible collapsible collapseProps={{ defaultIsOpen: false }} collapseProps={{ isOpen: open, onToggle: () => setOpen(prev => !prev) }} > > {error && ( <SectionCard padded> <SectionCard padded> <p>Error: {error.message}</p> <form onSubmit={handleSubmit}> </SectionCard> <FormGroup )} label='Old Password' {fetching && <CenteredSpinner />} intent={errors.oldPassword ? 'danger' : 'none'} {(!data || !data.passwordChange?.passwordChanged) && ( helperText={errors.oldPassword ? errors.oldPassword : undefined} <ChangePasswordSetting onSubmit={handler} /> > <InputGroup intent={errors.oldPassword ? 'danger' : 'none'} placeholder='Old Password' value={oldPassword} onChange={e => setOldPassword(e.target.value)} type='password' /> </FormGroup> <FormGroup label='New Password' intent={errors.newPassword ? 'danger' : 'none'} helperText={errors.newPassword ? errors.newPassword : undefined} > <InputGroup intent={errors.oldPassword ? 'danger' : 'none'} placeholder='New Password' value={newPassword} onChange={e => setNewPassword(e.target.value)} type='password' /> </FormGroup> {errors.submit && ( <ErrorMessage minimal>{errors.submit}</ErrorMessage> )} )} {data?.passwordChange?.passwordChanged && <p>Password changed</p>} <Button disabled={wiggling} type='submit' intent='primary' className={cx({ [wiggleClass]: wiggling, [marginTop]: !!errors.submit, })} loading={loading} > Submit </Button> </form> </SectionCard> </Section> </Section> ) ) } }
frontend/src/components/OnDemandStartAlert/index.tsx +1 −0 Original line number Original line Diff line number Diff line Loading @@ -84,6 +84,7 @@ export const OnDemandStartAlert: FC<OnDemandStartAlertProps> = ({ loading={fetching} loading={fetching} cancelButtonText='Cancel' cancelButtonText='Cancel' onCancel={() => onCancel={() => // TODO: if only one team, navigate to exercise selection nav({ nav({ to: TraineeExerciseRoute.to, to: TraineeExerciseRoute.to, params: { params: { Loading