Move "change password" to settings (#9079)

- Close https://github.com/enso-org/cloud-v2/issues/893
- Remove "Change Password" modal
- Remove "Change Password" option from user menu
- Add "Change Password" section to "General -> Account" in settings

# Important Notes
None
This commit is contained in:
somebody1234 2024-02-27 01:50:00 +10:00 committed by GitHub
parent 207f6a06e5
commit d71e8c4742
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 326 additions and 357 deletions

View File

@ -1,9 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.2" y="3" width="16" height="10" rx="2" fill="black" />
<path d="M10 0H12V16H10V0Z" fill="black" />
<path d="M8 0H14V2H8V0Z" fill="black" />
<path d="M8 14H14V16H8V14Z" fill="black" />
<path d="M2 8L8 8" stroke="black" />
<path d="M6.5 10.6L3.5 5.4" stroke="black" />
<path d="M3.5 10.6L6.5 5.4" stroke="black" />
</svg>

Before

Width:  |  Height:  |  Size: 460 B

View File

@ -35,9 +35,9 @@ export function locateConfirmPasswordInput(page: test.Locator | test.Page) {
return page.getByLabel('Confirm password') return page.getByLabel('Confirm password')
} }
/** Find an "old password" input (if any) on the current page. */ /** Find a "current password" input (if any) on the current page. */
export function locateOldPasswordInput(page: test.Locator | test.Page) { export function locateCurrentPasswordInput(page: test.Locator | test.Page) {
return page.getByLabel('Old password') return page.getByPlaceholder('Enter your current password')
} }
/** Find a "new password" input (if any) on the current page. */ /** Find a "new password" input (if any) on the current page. */
@ -94,41 +94,34 @@ export function locateAssetRowName(locator: test.Locator) {
// === Button locators === // === Button locators ===
/** Find a toast close button (if any) on the current page. */ /** Find a toast close button (if any) on the current locator. */
export function locateToastCloseButton(page: test.Locator | test.Page) { export function locateToastCloseButton(page: test.Locator | test.Page) {
// There is no other simple way to uniquely identify this element. // There is no other simple way to uniquely identify this element.
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
return page.locator('.Toastify__close-button') return page.locator('.Toastify__close-button')
} }
/** Find a login button (if any) on the current page. */ /** Find a "login" button (if any) on the current locator. */
export function locateLoginButton(page: test.Locator | test.Page) { export function locateLoginButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login') return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login')
} }
/** Find a register button (if any) on the current page. */ /** Find a "register" button (if any) on the current locator. */
export function locateRegisterButton(page: test.Locator | test.Page) { export function locateRegisterButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Register' }).getByText('Register') return page.getByRole('button', { name: 'Register' }).getByText('Register')
} }
/** Find a reset button (if any) on the current page. */ /** Find a "change" button (if any) on the current locator. */
export function locateResetButton(page: test.Locator | test.Page) { export function locateChangeButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Reset' }).getByText('Reset') return page.getByRole('button', { name: 'Change' }).getByText('Change')
} }
/** Find a user menu button (if any) on the current page. */ /** Find a user menu button (if any) on the current locator. */
export function locateUserMenuButton(page: test.Locator | test.Page) { export function locateUserMenuButton(page: test.Locator | test.Page) {
return page.getByAltText('Open user menu') return page.getByAltText('Open user menu')
} }
/** Find a change password button (if any) on the current page. */ /** Find a "sign out" button (if any) on the current locator. */
export function locateChangePasswordButton(page: test.Locator | test.Page) {
return page
.getByRole('button', { name: 'Change your password' })
.getByText('Change your password')
}
/** Find a "sign out" button (if any) on the current page. */
export function locateLogoutButton(page: test.Locator | test.Page) { export function locateLogoutButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Logout' }).getByText('Logout') return page.getByRole('button', { name: 'Logout' }).getByText('Logout')
} }
@ -498,12 +491,6 @@ export function locateCollapsibleDirectories(page: test.Page) {
return locateAssetRows(page).filter({ has: page.getByAltText('Collapse') }) return locateAssetRows(page).filter({ has: page.getByAltText('Collapse') })
} }
/** Find a "change password" modal (if any) on the current page. */
export function locateChangePasswordModal(page: test.Locator | test.Page) {
// This has no identifying features.
return page.getByTestId('change-password-modal')
}
/** Find a "confirm delete" modal (if any) on the current page. */ /** Find a "confirm delete" modal (if any) on the current page. */
export function locateConfirmDeleteModal(page: test.Locator | test.Page) { export function locateConfirmDeleteModal(page: test.Locator | test.Page) {
// This has no identifying features. // This has no identifying features.
@ -645,12 +632,11 @@ export async function press(page: test.Page, keyOrShortcut: string) {
await test.test.step('Detect browser OS', async () => { await test.test.step('Detect browser OS', async () => {
userAgent = await page.evaluate(() => navigator.userAgent) userAgent = await page.evaluate(() => navigator.userAgent)
}) })
// This should be `Meta` (`Cmd`) on macOS, and `Control` on all other systems const isMacOS = /\bMac OS\b/i.test(userAgent)
const ctrlKey = /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control' const ctrlKey = isMacOS ? 'Meta' : 'Control'
const deleteKey = /\bMac OS\b/i.test(userAgent) ? 'Backspace' : 'Delete' const deleteKey = isMacOS ? 'Backspace' : 'Delete'
await page.keyboard.press( const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey)
keyOrShortcut.replace(/\bMod\b/g, ctrlKey).replace(/\bDelete\b/, deleteKey) await test.test.step(`Press '${shortcut}'`, () => page.keyboard.press(shortcut))
)
} else { } else {
await page.keyboard.press(keyOrShortcut) await page.keyboard.press(keyOrShortcut)
} }

View File

@ -59,6 +59,7 @@ export async function mockApi({ page }: MockParams) {
rootDirectoryId: defaultDirectoryId, rootDirectoryId: defaultDirectoryId,
} }
let currentUser: backend.User | null = defaultUser let currentUser: backend.User | null = defaultUser
let currentOrganization: backend.OrganizationInfo | null = null
const assetMap = new Map<backend.AssetId, backend.AnyAsset>() const assetMap = new Map<backend.AssetId, backend.AnyAsset>()
const deletedAssets = new Set<backend.AssetId>() const deletedAssets = new Set<backend.AssetId>()
const assets: backend.AnyAsset[] = [] const assets: backend.AnyAsset[] = []
@ -578,8 +579,13 @@ export async function mockApi({ page }: MockParams) {
} }
) )
await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => { await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => {
await route.fulfill({ json: currentUser })
})
await page.route(BASE_URL + remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async route => {
await route.fulfill({ await route.fulfill({
json: currentUser, json: currentOrganization,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
status: currentOrganization == null ? 404 : 200,
}) })
}) })
await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => { await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => {
@ -696,6 +702,14 @@ export async function mockApi({ page }: MockParams) {
setCurrentUser: (user: backend.User | null) => { setCurrentUser: (user: backend.User | null) => {
currentUser = user currentUser = user
}, },
/** Returns the current value of `currentUser`. This is a getter, so its return value
* SHOULD NOT be cached. */
get currentOrganization() {
return currentOrganization
},
setCurrentOrganization: (user: backend.OrganizationInfo | null) => {
currentOrganization = user
},
addAsset, addAsset,
deleteAsset, deleteAsset,
undeleteAsset, undeleteAsset,

View File

@ -0,0 +1,58 @@
/** @file Test the "change password" modal. */
import * as test from '@playwright/test'
import * as actions from './actions'
test.test.beforeEach(actions.mockAllAndLogin)
test.test('change password', async ({ page }) => {
await actions.press(page, 'Mod+,')
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await test
.expect(actions.locateChangeButton(page), 'incomplete form should be rejected')
.toBeDisabled()
// Invalid new password
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await actions
.locateNewPasswordInput(page)
.evaluate((element: HTMLInputElement) => element.validity.valid),
'invalid new password should be rejected'
)
.toBe(false)
await test
.expect(actions.locateChangeButton(page), 'invalid new password should be rejected')
.toBeDisabled()
// Invalid new password confirmation
await actions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
test
.expect(
await actions
.locateConfirmNewPasswordInput(page)
.evaluate((element: HTMLInputElement) => element.validity.valid),
'invalid new password confirmation should be rejected'
)
.toBe(false)
await test
.expect(
actions.locateChangeButton(page),
'invalid new password confirmation should be rejected'
)
.toBeDisabled()
// After form submission
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateChangeButton(page).click()
await test.expect(actions.locateCurrentPasswordInput(page)).toHaveText('')
await test.expect(actions.locateNewPasswordInput(page)).toHaveText('')
await test.expect(actions.locateConfirmNewPasswordInput(page)).toHaveText('')
})

View File

@ -1,50 +0,0 @@
/** @file Test the "change password" modal. */
import * as test from '@playwright/test'
import * as actions from './actions'
test.test.beforeEach(actions.mockAllAndLogin)
test.test('change password modal', async ({ page }) => {
// Change password modal
await actions.locateUserMenuButton(page).click()
await actions.locateChangePasswordButton(page).click()
await test.expect(actions.locateChangePasswordModal(page)).toBeVisible()
// Invalid old password
await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
'form should reject invalid old password'
)
.toBe(false)
await actions.locateResetButton(page).click()
// Invalid new password
await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
'form should reject invalid new password'
)
.toBe(false)
await actions.locateResetButton(page).click()
// Invalid new password confirmation
await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
'form should reject invalid new password confirmation'
)
.toBe(false)
await actions.locateResetButton(page).click()
// After form submission
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateResetButton(page).click()
await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached()
})

View File

@ -22,13 +22,14 @@ export interface InputProps extends controlledInput.ControlledInputProps {
/** A styled input that includes an icon. */ /** A styled input that includes an icon. */
export default function Input(props: InputProps) { export default function Input(props: InputProps) {
const { allowShowingPassword = false, label, icon, footer, ...passthrough } = props const { allowShowingPassword = false, label, icon, type, footer, ...passthrough } = props
const [isShowingPassword, setIsShowingPassword] = React.useState(false) const [isShowingPassword, setIsShowingPassword] = React.useState(false)
const input = ( const input = (
<div className="relative"> <div className="relative">
<SvgIcon src={icon} /> <SvgIcon src={icon} />
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : props.type} /> <ControlledInput {...passthrough} type={isShowingPassword ? 'text' : type} />
{props.type === 'password' && allowShowingPassword && ( {type === 'password' && allowShowingPassword && (
<SvgIcon <SvgIcon
src={isShowingPassword ? EyeIcon : EyeCrossedIcon} src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
className="cursor-pointer rounded-full" className="cursor-pointer rounded-full"
@ -40,13 +41,14 @@ export default function Input(props: InputProps) {
)} )}
</div> </div>
) )
return label != null || footer != null ? (
return label == null && footer == null ? (
input
) : (
<label className="flex flex-col gap-1"> <label className="flex flex-col gap-1">
{label} {label}
{input} {input}
{footer} {footer}
</label> </label>
) : (
input
) )
} }

View File

@ -1,103 +0,0 @@
/** @file A modal for changing the user's password. */
import * as React from 'react'
import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import LockIcon from 'enso-assets/lock.svg'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import Input from '#/components/Input'
import Modal from '#/components/Modal'
import SubmitButton from '#/components/SubmitButton'
import * as string from '#/utilities/string'
import * as validation from '#/utilities/validation'
// ===========================
// === ChangePasswordModal ===
// ===========================
/** A modal for changing the user's password. */
export default function ChangePasswordModal() {
const { user } = authProvider.useNonPartialUserSession()
const { changePassword } = authProvider.useAuth()
const { unsetModal } = modalProvider.useSetModal()
const [oldPassword, setOldPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
return (
<Modal centered className="bg-dim">
<form
data-testid="change-password-modal"
className="flex flex-col gap-6 bg-frame-selected backdrop-blur-3xl rounded-2xl p-8 w-full max-w-md"
onSubmit={async event => {
event.preventDefault()
setIsSubmitting(true)
const success = await changePassword(oldPassword, newPassword)
setIsSubmitting(false)
if (success) {
unsetModal()
}
}}
onClick={event => {
event.stopPropagation()
}}
>
<div className="self-center text-xl">Change your password</div>
<input type="text" autoComplete="username" hidden value={user?.email} />
<Input
autoFocus
required
validate
allowShowingPassword
id="old_password"
type="password"
name="old_password"
autoComplete="current-password"
label="Old password"
icon={LockIcon}
placeholder="Enter your old password"
error={validation.PASSWORD_ERROR}
value={oldPassword}
setValue={setOldPassword}
className="text-sm placeholder-gray-500 pl-10 pr-4 rounded-full w-full py-2"
/>
<Input
required
validate
allowShowingPassword
type="password"
autoComplete="new-password"
label="New password"
icon={LockIcon}
placeholder="Enter your new password"
pattern={validation.PASSWORD_PATTERN}
error={validation.PASSWORD_ERROR}
value={newPassword}
setValue={setNewPassword}
className="text-sm placeholder-gray-500 pl-10 pr-4 rounded-full w-full py-2"
/>
<Input
required
validate
allowShowingPassword
type="password"
autoComplete="new-password"
label="Confirm new password"
icon={LockIcon}
placeholder="Confirm your new password"
pattern={string.regexEscape(newPassword)}
error={validation.CONFIRM_PASSWORD_ERROR}
value={confirmNewPassword}
setValue={setConfirmNewPassword}
className="text-sm placeholder-gray-500 pl-10 pr-4 rounded-full w-full py-2"
/>
<SubmitButton disabled={isSubmitting} text="Reset" icon={ArrowRightIcon} />
</form>
</Modal>
)
}

View File

@ -2,6 +2,8 @@
import * as React from 'react' import * as React from 'react'
import DefaultUserIcon from 'enso-assets/default_user.svg' import DefaultUserIcon from 'enso-assets/default_user.svg'
import EyeCrossedIcon from 'enso-assets/eye_crossed.svg'
import EyeIcon from 'enso-assets/eye.svg'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -9,46 +11,91 @@ import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider' import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import SvgMask from '#/components/SvgMask'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as uniqueString from '#/utilities/uniqueString'
import * as validation from '#/utilities/validation'
import ConfirmDeleteUserModal from '../ConfirmDeleteUserModal' import ConfirmDeleteUserModal from '../ConfirmDeleteUserModal'
// ================= // =============
// === InfoEntry === // === Input ===
// ================= // =============
/** Props for a transparent wrapper component. */ /** Props for an {@link Input}. */
interface InternalTransparentWrapperProps { interface InternalInputProps {
readonly children: React.ReactNode readonly originalValue: string
readonly type?: string
readonly placeholder?: string
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>
readonly onSubmit?: (value: string) => void
} }
/** A transparent wrapper component */ /** A styled input. */
// This is a React component even though it does not contain JSX. function Input(props: InternalInputProps) {
// eslint-disable-next-line no-restricted-syntax const { originalValue, type, placeholder, onChange, onSubmit } = props
function Name(props: InternalTransparentWrapperProps) { const [isShowingPassword, setIsShowingPassword] = React.useState(false)
return props.children const cancelled = React.useRef(false)
}
/** A transparent wrapper component */ const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
// This is a React component even though it does not contain JSX. switch (event.key) {
// eslint-disable-next-line no-restricted-syntax case 'Escape': {
function Value(props: InternalTransparentWrapperProps) { cancelled.current = true
return props.children event.stopPropagation()
} event.currentTarget.value = originalValue
event.currentTarget.blur()
break
}
case 'Enter': {
cancelled.current = false
event.stopPropagation()
event.currentTarget.blur()
break
}
case 'Tab': {
cancelled.current = false
event.currentTarget.blur()
break
}
default: {
cancelled.current = false
break
}
}
}
/** Props for a {@link InfoEntry}. */ const input = (
interface InternalInfoEntryProps { <input
readonly children: [React.ReactNode, React.ReactNode] className="rounded-full font-bold leading-5 w-full h-6 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors placeholder-primary/30 invalid:border invalid:border-red-700"
} type={isShowingPassword ? 'text' : type}
size={1}
defaultValue={originalValue}
placeholder={placeholder}
onKeyDown={onKeyDown}
onChange={onChange}
onBlur={event => {
if (!cancelled.current) {
onSubmit?.(event.currentTarget.value)
}
}}
/>
)
/** Styled information display containing key and value. */ return type !== 'password' ? (
function InfoEntry(props: InternalInfoEntryProps) { input
const { children } = props ) : (
const [name, value] = children <div className="relative">
return ( {input}
<div className="flex gap-4.75"> {
<span className="leading-5 w-12 h-8 py-1.25">{name}</span> <SvgMask
<span className="grow font-bold leading-5 h-8 py-1.25">{value}</span> src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
className="absolute cursor-pointer rounded-full right-2 top-1"
onClick={() => {
setIsShowingPassword(show => !show)
}}
/>
}
</div> </div>
) )
} }
@ -60,15 +107,24 @@ function InfoEntry(props: InternalInfoEntryProps) {
/** Settings tab for viewing and editing account information. */ /** Settings tab for viewing and editing account information. */
export default function AccountSettingsTab() { export default function AccountSettingsTab() {
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setUser, signOut } = authProvider.useAuth() const { setUser, changePassword, signOut } = authProvider.useAuth()
const { setModal } = modalProvider.useSetModal() const { setModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const { user } = authProvider.useNonPartialUserSession() const { user, accessToken } = authProvider.useNonPartialUserSession()
const nameInputRef = React.useRef<HTMLInputElement>(null) const [passwordFormKey, setPasswordFormKey] = React.useState('')
const [currentPassword, setCurrentPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
const doUpdateName = async () => { // The shape of the JWT payload is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const username: string | null =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
const doUpdateName = async (newName: string) => {
const oldName = user?.name ?? '' const oldName = user?.name ?? ''
const newName = nameInputRef.current?.value ?? ''
if (newName === oldName) { if (newName === oldName) {
return return
} else { } else {
@ -77,9 +133,6 @@ export default function AccountSettingsTab() {
setUser(object.merger({ name: newName })) setUser(object.merger({ name: newName }))
} catch (error) { } catch (error) {
toastAndLog(null, error) toastAndLog(null, error)
if (nameInputRef.current) {
nameInputRef.current.value = oldName
}
} }
return return
} }
@ -108,45 +161,106 @@ export default function AccountSettingsTab() {
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<h3 className="font-bold text-xl h-9.5 py-0.5">User Account</h3> <h3 className="font-bold text-xl h-9.5 py-0.5">User Account</h3>
<div className="flex flex-col"> <div className="flex flex-col">
<InfoEntry> <div className="flex gap-4.75">
<Name>Name</Name> <span className="leading-5 w-12 h-8 py-1.25">Name</span>
<Value> <span className="grow font-bold leading-5 h-8 py-1.25">
<input <Input originalValue={user?.name ?? ''} onSubmit={doUpdateName} />
ref={nameInputRef} </span>
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors" </div>
key={user?.name} <div className="flex gap-4.75">
type="text" <span className="leading-5 w-12 h-8 py-1.25">Email</span>
size={1} <span className="grow font-bold leading-5 h-8 py-1.25">{user?.email ?? ''}</span>
defaultValue={user?.name ?? ''} </div>
onBlur={doUpdateName} </div>
onKeyDown={event => { </div>
switch (event.key) { {canChangePassword && (
case 'Escape': { <div key={passwordFormKey}>
event.stopPropagation() <h3 className="font-bold text-xl h-9.5 py-0.5">Change Password</h3>
event.currentTarget.value = user?.name ?? '' <div className="flex gap-4.75">
event.currentTarget.blur() <span className="leading-5 w-36 h-8 py-1.25">Current Password</span>
break <span className="grow font-bold leading-5 h-8 py-1.25">
} <Input
case 'Enter': { type="password"
event.stopPropagation() originalValue=""
event.currentTarget.blur() placeholder="Enter your current password"
break onChange={event => {
} setCurrentPassword(event.currentTarget.value)
case 'Tab': {
event.currentTarget.blur()
break
}
}
}} }}
/> />
</Value> </span>
</InfoEntry> </div>
<InfoEntry> <div className="flex gap-4.75">
<Name>Email</Name> <span className="leading-5 w-36 h-8 py-1.25">New Password</span>
<Value>{user?.email ?? ''}</Value> <span className="grow font-bold leading-5 h-8 py-1.25">
</InfoEntry> <Input
type="password"
originalValue=""
placeholder="Enter your new password"
onChange={event => {
const newValue = event.currentTarget.value
setNewPassword(newValue)
event.currentTarget.setCustomValidity(
newValue === '' || validation.PASSWORD_REGEX.test(newValue)
? ''
: validation.PASSWORD_ERROR
)
}}
/>
</span>
</div>
<div className="flex gap-4.75">
<span className="leading-5 w-36 h-8 py-1.25">Confirm New Password</span>
<span className="grow font-bold leading-5 h-8 py-1.25">
<Input
type="password"
originalValue=""
placeholder="Confirm your new password"
onChange={event => {
const newValue = event.currentTarget.value
setConfirmNewPassword(newValue)
event.currentTarget.setCustomValidity(
newValue === '' || newValue === newPassword ? '' : 'Passwords must match.'
)
}}
/>
</span>
</div>
<div className="flex gap-2">
<button
disabled={
currentPassword === '' ||
newPassword === '' ||
confirmNewPassword === '' ||
newPassword !== confirmNewPassword ||
!validation.PASSWORD_REGEX.test(newPassword)
}
type="submit"
className="text-white bg-invite font-medium rounded-full h-6 py-px px-2 -my-px disabled:opacity-50"
onClick={() => {
setPasswordFormKey(uniqueString.uniqueString())
setCurrentPassword('')
setNewPassword('')
setConfirmNewPassword('')
void changePassword(currentPassword, newPassword)
}}
>
Change
</button>
<button
type="button"
className="bg-frame-selected font-medium rounded-full h-6 py-px px-2 -my-px"
onClick={() => {
setPasswordFormKey(uniqueString.uniqueString())
setCurrentPassword('')
setNewPassword('')
setConfirmNewPassword('')
}}
>
Cancel
</button>
</div> </div>
</div> </div>
)}
<div className="flex flex-col gap-2.5 rounded-2.5xl border-2 border-danger px-4 pt-2.25 pb-3.75"> <div className="flex flex-col gap-2.5 rounded-2.5xl border-2 border-danger px-4 pt-2.25 pb-3.75">
<h3 className="text-danger font-bold text-xl h-9.5 py-0.5">Danger Zone</h3> <h3 className="text-danger font-bold text-xl h-9.5 py-0.5">Danger Zone</h3>
<div className="flex gap-2"> <div className="flex gap-2">

View File

@ -13,46 +13,6 @@ import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
// =================
// === InfoEntry ===
// =================
/** Props for a transparent wrapper component. */
interface InternalTransparentWrapperProps {
readonly children: React.ReactNode
}
/** A transparent wrapper component */
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
function Name(props: InternalTransparentWrapperProps) {
return props.children
}
/** A transparent wrapper component */
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
function Value(props: InternalTransparentWrapperProps) {
return props.children
}
/** Props for a {@link InfoEntry}. */
interface InternalInfoEntryProps {
readonly children: [React.ReactNode, React.ReactNode]
}
/** Styled information display containing key and value. */
function InfoEntry(props: InternalInfoEntryProps) {
const { children } = props
const [name, value] = children
return (
<div className="flex gap-4.75">
<span className="leading-5 w-40 h-8 py-1.25">{name}</span>
<span className="grow font-bold leading-5 h-8 py-1.25">{value}</span>
</div>
)
}
// =============================== // ===============================
// === OrganizationSettingsTab === // === OrganizationSettingsTab ===
// =============================== // ===============================
@ -202,9 +162,9 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<h3 className="font-bold text-xl h-9.5 py-0.5">Organization</h3> <h3 className="font-bold text-xl h-9.5 py-0.5">Organization</h3>
<div className="flex flex-col"> <div className="flex flex-col">
<InfoEntry> <div className="flex gap-4.75">
<Name>Organization display name</Name> <span className="leading-5 w-40 h-8 py-1.25">Organization display name</span>
<Value> <span className="grow font-bold leading-5 h-8 py-1.25">
<input <input
ref={nameRef} ref={nameRef}
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors" className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors"
@ -217,11 +177,28 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
onKeyDown(event, organization.organization_name ?? '') onKeyDown(event, organization.organization_name ?? '')
}} }}
/> />
</Value> </span>
</InfoEntry> </div>
<InfoEntry> <div className="flex gap-4.75">
<Name>Email</Name> <span className="leading-5 w-40 h-8 py-1.25">Organization display name</span>
<Value> <span className="grow font-bold leading-5 h-8 py-1.25">
<input
ref={nameRef}
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors"
key={organization.organization_name}
type="text"
size={1}
defaultValue={organization.organization_name ?? ''}
onBlur={doUpdateName}
onKeyDown={event => {
onKeyDown(event, organization.organization_name ?? '')
}}
/>
</span>
</div>
<div className="flex gap-4.75">
<span className="leading-5 w-40 h-8 py-1.25">Email</span>
<span className="grow font-bold leading-5 h-8 py-1.25">
<input <input
ref={emailRef} ref={emailRef}
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors invalid:border invalid:border-red-700" className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors invalid:border invalid:border-red-700"
@ -245,11 +222,11 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
) )
}} }}
/> />
</Value> </span>
</InfoEntry> </div>
<InfoEntry> <div className="flex gap-4.75">
<Name>Website</Name> <span className="leading-5 w-40 h-8 py-1.25">Website</span>
<Value> <span className="grow font-bold leading-5 h-8 py-1.25">
<input <input
ref={websiteRef} ref={websiteRef}
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors" className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors"
@ -262,11 +239,11 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
onKeyDown(event, organization.website ?? '') onKeyDown(event, organization.website ?? '')
}} }}
/> />
</Value> </span>
</InfoEntry> </div>
<InfoEntry> <div className="flex gap-4.75">
<Name>Location</Name> <span className="leading-5 w-40 h-8 py-1.25">Location</span>
<Value> <span className="grow font-bold leading-5 h-8 py-1.25">
<input <input
ref={locationRef} ref={locationRef}
className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors" className="rounded-full font-bold leading-5 w-full h-8 -mx-2 -my-1.25 px-2 py-1.25 bg-transparent hover:bg-frame-selected focus:bg-frame-selected transition-colors"
@ -279,8 +256,8 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
onKeyDown(event, organization.address ?? '') onKeyDown(event, organization.address ?? '')
}} }}
/> />
</Value> </span>
</InfoEntry> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,7 +11,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import ChangePasswordModal from '#/layouts/dashboard/ChangePasswordModal'
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher' import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
import MenuEntry from '#/components/MenuEntry' import MenuEntry from '#/components/MenuEntry'
@ -39,17 +38,10 @@ export default function UserMenu(props: UserMenuProps) {
const { hidden = false, setPage, supportsLocalBackend, onSignOut } = props const { hidden = false, setPage, supportsLocalBackend, onSignOut } = props
const navigate = navigateHooks.useNavigate() const navigate = navigateHooks.useNavigate()
const { signOut } = authProvider.useAuth() const { signOut } = authProvider.useAuth()
const { accessToken, user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
// The shape of the JWT payload is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const username: string | null =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
return ( return (
<Modal hidden={hidden} className="absolute overflow-hidden bg-dim w-full h-full"> <Modal hidden={hidden} className="absolute overflow-hidden bg-dim w-full h-full">
<div <div
@ -70,15 +62,6 @@ export default function UserMenu(props: UserMenuProps) {
<span className="leading-170 h-6 py-px">{user.name}</span> <span className="leading-170 h-6 py-px">{user.name}</span>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
{canChangePassword && (
<MenuEntry
action={shortcutManager.KeyboardAction.changeYourPassword}
paddingClassName="p-1"
doAction={() => {
setModal(<ChangePasswordModal />)
}}
/>
)}
{!supportsLocalBackend && ( {!supportsLocalBackend && (
<MenuEntry <MenuEntry
action={shortcutManager.KeyboardAction.downloadApp} action={shortcutManager.KeyboardAction.downloadApp}

View File

@ -8,7 +8,6 @@ import AddNetworkIcon from 'enso-assets/add_network.svg'
import AppDownloadIcon from 'enso-assets/app_download.svg' import AppDownloadIcon from 'enso-assets/app_download.svg'
import BlankIcon from 'enso-assets/blank_16.svg' import BlankIcon from 'enso-assets/blank_16.svg'
import CameraIcon from 'enso-assets/camera.svg' import CameraIcon from 'enso-assets/camera.svg'
import ChangePasswordIcon from 'enso-assets/change_password.svg'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
import CloudToIcon from 'enso-assets/cloud_to.svg' import CloudToIcon from 'enso-assets/cloud_to.svg'
import CopyIcon from 'enso-assets/copy.svg' import CopyIcon from 'enso-assets/copy.svg'
@ -87,7 +86,6 @@ export enum KeyboardAction {
newDataLink = 'new-data-link', newDataLink = 'new-data-link',
closeModal = 'close-modal', closeModal = 'close-modal',
cancelEditName = 'cancel-edit-name', cancelEditName = 'cancel-edit-name',
changeYourPassword = 'change-your-password',
signIn = 'sign-in', signIn = 'sign-in',
signOut = 'sign-out', signOut = 'sign-out',
downloadApp = 'download-app', downloadApp = 'download-app',
@ -471,7 +469,6 @@ const DEFAULT_KEYBOARD_SHORTCUTS: Readonly<Record<KeyboardAction, KeyboardShortc
], ],
[KeyboardAction.closeModal]: [keybind(KeyboardAction.closeModal, [], 'Escape')], [KeyboardAction.closeModal]: [keybind(KeyboardAction.closeModal, [], 'Escape')],
[KeyboardAction.cancelEditName]: [keybind(KeyboardAction.cancelEditName, [], 'Escape')], [KeyboardAction.cancelEditName]: [keybind(KeyboardAction.cancelEditName, [], 'Escape')],
[KeyboardAction.changeYourPassword]: [],
[KeyboardAction.signIn]: [], [KeyboardAction.signIn]: [],
[KeyboardAction.signOut]: [], [KeyboardAction.signOut]: [],
[KeyboardAction.downloadApp]: [], [KeyboardAction.downloadApp]: [],
@ -520,7 +517,6 @@ const DEFAULT_KEYBOARD_SHORTCUT_INFO: Readonly<Record<KeyboardAction, ShortcutIn
// These should not appear in any context menus. // These should not appear in any context menus.
[KeyboardAction.closeModal]: { name: 'Close', icon: BlankIcon }, [KeyboardAction.closeModal]: { name: 'Close', icon: BlankIcon },
[KeyboardAction.cancelEditName]: { name: 'Cancel Editing', icon: BlankIcon }, [KeyboardAction.cancelEditName]: { name: 'Cancel Editing', icon: BlankIcon },
[KeyboardAction.changeYourPassword]: { name: 'Change Your Password', icon: ChangePasswordIcon },
[KeyboardAction.signIn]: { name: 'Login', icon: SignInIcon }, [KeyboardAction.signIn]: { name: 'Login', icon: SignInIcon },
[KeyboardAction.signOut]: { name: 'Logout', icon: SignOutIcon, colorClass: 'text-delete' }, [KeyboardAction.signOut]: { name: 'Logout', icon: SignOutIcon, colorClass: 'text-delete' },
[KeyboardAction.downloadApp]: { name: 'Download App', icon: AppDownloadIcon }, [KeyboardAction.downloadApp]: { name: 'Download App', icon: AppDownloadIcon },

View File

@ -22,6 +22,7 @@ export const PASSWORD_PATTERN =
export const PASSWORD_ERROR = export const PASSWORD_ERROR =
'Your password must include numbers, letters (both lowercase and uppercase) and symbols, ' + 'Your password must include numbers, letters (both lowercase and uppercase) and symbols, ' +
'and must be between 6 and 256 characters long.' 'and must be between 6 and 256 characters long.'
export const PASSWORD_REGEX = new RegExp('^' + PASSWORD_PATTERN + '$')
export const CONFIRM_PASSWORD_ERROR = 'Passwords must match.' export const CONFIRM_PASSWORD_ERROR = 'Passwords must match.'