mirror of
https://github.com/enso-org/enso.git
synced 2024-12-25 05:02:40 +03:00
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:
parent
207f6a06e5
commit
d71e8c4742
@ -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 |
@ -35,9 +35,9 @@ export function locateConfirmPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Confirm password')
|
||||
}
|
||||
|
||||
/** Find an "old password" input (if any) on the current page. */
|
||||
export function locateOldPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Old password')
|
||||
/** Find a "current password" input (if any) on the current page. */
|
||||
export function locateCurrentPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your current password')
|
||||
}
|
||||
|
||||
/** Find a "new password" input (if any) on the current page. */
|
||||
@ -94,41 +94,34 @@ export function locateAssetRowName(locator: test.Locator) {
|
||||
|
||||
// === 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) {
|
||||
// There is no other simple way to uniquely identify this element.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
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) {
|
||||
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) {
|
||||
return page.getByRole('button', { name: 'Register' }).getByText('Register')
|
||||
}
|
||||
|
||||
/** Find a reset button (if any) on the current page. */
|
||||
export function locateResetButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Reset' }).getByText('Reset')
|
||||
/** Find a "change" button (if any) on the current locator. */
|
||||
export function locateChangeButton(page: test.Locator | test.Page) {
|
||||
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) {
|
||||
return page.getByAltText('Open user menu')
|
||||
}
|
||||
|
||||
/** Find a change password button (if any) on the current page. */
|
||||
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. */
|
||||
/** Find a "sign out" button (if any) on the current locator. */
|
||||
export function locateLogoutButton(page: test.Locator | test.Page) {
|
||||
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') })
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function locateConfirmDeleteModal(page: test.Locator | test.Page) {
|
||||
// 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 () => {
|
||||
userAgent = await page.evaluate(() => navigator.userAgent)
|
||||
})
|
||||
// This should be `Meta` (`Cmd`) on macOS, and `Control` on all other systems
|
||||
const ctrlKey = /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
|
||||
const deleteKey = /\bMac OS\b/i.test(userAgent) ? 'Backspace' : 'Delete'
|
||||
await page.keyboard.press(
|
||||
keyOrShortcut.replace(/\bMod\b/g, ctrlKey).replace(/\bDelete\b/, deleteKey)
|
||||
)
|
||||
const isMacOS = /\bMac OS\b/i.test(userAgent)
|
||||
const ctrlKey = isMacOS ? 'Meta' : 'Control'
|
||||
const deleteKey = isMacOS ? 'Backspace' : 'Delete'
|
||||
const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey)
|
||||
await test.test.step(`Press '${shortcut}'`, () => page.keyboard.press(shortcut))
|
||||
} else {
|
||||
await page.keyboard.press(keyOrShortcut)
|
||||
}
|
||||
|
@ -59,6 +59,7 @@ export async function mockApi({ page }: MockParams) {
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
}
|
||||
let currentUser: backend.User | null = defaultUser
|
||||
let currentOrganization: backend.OrganizationInfo | null = null
|
||||
const assetMap = new Map<backend.AssetId, backend.AnyAsset>()
|
||||
const deletedAssets = new Set<backend.AssetId>()
|
||||
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 route.fulfill({ json: currentUser })
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.GET_ORGANIZATION_PATH + '*', async route => {
|
||||
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 => {
|
||||
@ -696,6 +702,14 @@ export async function mockApi({ page }: MockParams) {
|
||||
setCurrentUser: (user: backend.User | null) => {
|
||||
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,
|
||||
deleteAsset,
|
||||
undeleteAsset,
|
||||
|
58
app/ide-desktop/lib/dashboard/e2e/changePassword.spec.ts
Normal file
58
app/ide-desktop/lib/dashboard/e2e/changePassword.spec.ts
Normal 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('')
|
||||
})
|
@ -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()
|
||||
})
|
@ -22,13 +22,14 @@ export interface InputProps extends controlledInput.ControlledInputProps {
|
||||
|
||||
/** A styled input that includes an icon. */
|
||||
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 input = (
|
||||
<div className="relative">
|
||||
<SvgIcon src={icon} />
|
||||
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : props.type} />
|
||||
{props.type === 'password' && allowShowingPassword && (
|
||||
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : type} />
|
||||
{type === 'password' && allowShowingPassword && (
|
||||
<SvgIcon
|
||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
className="cursor-pointer rounded-full"
|
||||
@ -40,13 +41,14 @@ export default function Input(props: InputProps) {
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return label != null || footer != null ? (
|
||||
|
||||
return label == null && footer == null ? (
|
||||
input
|
||||
) : (
|
||||
<label className="flex flex-col gap-1">
|
||||
{label}
|
||||
{input}
|
||||
{footer}
|
||||
</label>
|
||||
) : (
|
||||
input
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -2,6 +2,8 @@
|
||||
import * as React from 'react'
|
||||
|
||||
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'
|
||||
|
||||
@ -9,46 +11,91 @@ import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import * as validation from '#/utilities/validation'
|
||||
|
||||
import ConfirmDeleteUserModal from '../ConfirmDeleteUserModal'
|
||||
|
||||
// =================
|
||||
// === InfoEntry ===
|
||||
// =================
|
||||
// =============
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for a transparent wrapper component. */
|
||||
interface InternalTransparentWrapperProps {
|
||||
readonly children: React.ReactNode
|
||||
/** Props for an {@link Input}. */
|
||||
interface InternalInputProps {
|
||||
readonly originalValue: string
|
||||
readonly type?: string
|
||||
readonly placeholder?: string
|
||||
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>
|
||||
readonly onSubmit?: (value: string) => void
|
||||
}
|
||||
|
||||
/** 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 styled input. */
|
||||
function Input(props: InternalInputProps) {
|
||||
const { originalValue, type, placeholder, onChange, onSubmit } = props
|
||||
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
|
||||
const cancelled = React.useRef(false)
|
||||
|
||||
/** 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
|
||||
}
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
cancelled.current = true
|
||||
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}. */
|
||||
interface InternalInfoEntryProps {
|
||||
readonly children: [React.ReactNode, React.ReactNode]
|
||||
}
|
||||
const input = (
|
||||
<input
|
||||
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. */
|
||||
function InfoEntry(props: InternalInfoEntryProps) {
|
||||
const { children } = props
|
||||
const [name, value] = children
|
||||
return (
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-12 h-8 py-1.25">{name}</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">{value}</span>
|
||||
return type !== 'password' ? (
|
||||
input
|
||||
) : (
|
||||
<div className="relative">
|
||||
{input}
|
||||
{
|
||||
<SvgMask
|
||||
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
|
||||
className="absolute cursor-pointer rounded-full right-2 top-1"
|
||||
onClick={() => {
|
||||
setIsShowingPassword(show => !show)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -60,15 +107,24 @@ function InfoEntry(props: InternalInfoEntryProps) {
|
||||
/** Settings tab for viewing and editing account information. */
|
||||
export default function AccountSettingsTab() {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setUser, signOut } = authProvider.useAuth()
|
||||
const { setUser, changePassword, signOut } = authProvider.useAuth()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
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 newName = nameInputRef.current?.value ?? ''
|
||||
if (newName === oldName) {
|
||||
return
|
||||
} else {
|
||||
@ -77,9 +133,6 @@ export default function AccountSettingsTab() {
|
||||
setUser(object.merger({ name: newName }))
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.value = oldName
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -108,45 +161,106 @@ export default function AccountSettingsTab() {
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h3 className="font-bold text-xl h-9.5 py-0.5">User Account</h3>
|
||||
<div className="flex flex-col">
|
||||
<InfoEntry>
|
||||
<Name>Name</Name>
|
||||
<Value>
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
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={user?.name}
|
||||
type="text"
|
||||
size={1}
|
||||
defaultValue={user?.name ?? ''}
|
||||
onBlur={doUpdateName}
|
||||
onKeyDown={event => {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
event.stopPropagation()
|
||||
event.currentTarget.value = user?.name ?? ''
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
case 'Enter': {
|
||||
event.stopPropagation()
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
case 'Tab': {
|
||||
event.currentTarget.blur()
|
||||
break
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Value>
|
||||
</InfoEntry>
|
||||
<InfoEntry>
|
||||
<Name>Email</Name>
|
||||
<Value>{user?.email ?? ''}</Value>
|
||||
</InfoEntry>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-12 h-8 py-1.25">Name</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">
|
||||
<Input originalValue={user?.name ?? ''} onSubmit={doUpdateName} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-12 h-8 py-1.25">Email</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">{user?.email ?? ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{canChangePassword && (
|
||||
<div key={passwordFormKey}>
|
||||
<h3 className="font-bold text-xl h-9.5 py-0.5">Change Password</h3>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-36 h-8 py-1.25">Current Password</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">
|
||||
<Input
|
||||
type="password"
|
||||
originalValue=""
|
||||
placeholder="Enter your current password"
|
||||
onChange={event => {
|
||||
setCurrentPassword(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-36 h-8 py-1.25">New Password</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">
|
||||
<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 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>
|
||||
<div className="flex gap-2">
|
||||
|
@ -13,46 +13,6 @@ import * as backendModule from '#/services/Backend'
|
||||
|
||||
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 ===
|
||||
// ===============================
|
||||
@ -202,9 +162,9 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h3 className="font-bold text-xl h-9.5 py-0.5">Organization</h3>
|
||||
<div className="flex flex-col">
|
||||
<InfoEntry>
|
||||
<Name>Organization display name</Name>
|
||||
<Value>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-40 h-8 py-1.25">Organization display name</span>
|
||||
<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"
|
||||
@ -217,11 +177,28 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
|
||||
onKeyDown(event, organization.organization_name ?? '')
|
||||
}}
|
||||
/>
|
||||
</Value>
|
||||
</InfoEntry>
|
||||
<InfoEntry>
|
||||
<Name>Email</Name>
|
||||
<Value>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-40 h-8 py-1.25">Organization display name</span>
|
||||
<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
|
||||
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"
|
||||
@ -245,11 +222,11 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Value>
|
||||
</InfoEntry>
|
||||
<InfoEntry>
|
||||
<Name>Website</Name>
|
||||
<Value>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-40 h-8 py-1.25">Website</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">
|
||||
<input
|
||||
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"
|
||||
@ -262,11 +239,11 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
|
||||
onKeyDown(event, organization.website ?? '')
|
||||
}}
|
||||
/>
|
||||
</Value>
|
||||
</InfoEntry>
|
||||
<InfoEntry>
|
||||
<Name>Location</Name>
|
||||
<Value>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4.75">
|
||||
<span className="leading-5 w-40 h-8 py-1.25">Location</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">
|
||||
<input
|
||||
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"
|
||||
@ -279,8 +256,8 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
|
||||
onKeyDown(event, organization.address ?? '')
|
||||
}}
|
||||
/>
|
||||
</Value>
|
||||
</InfoEntry>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -11,7 +11,6 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import ChangePasswordModal from '#/layouts/dashboard/ChangePasswordModal'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
@ -39,17 +38,10 @@ export default function UserMenu(props: UserMenuProps) {
|
||||
const { hidden = false, setPage, supportsLocalBackend, onSignOut } = props
|
||||
const navigate = navigateHooks.useNavigate()
|
||||
const { signOut } = authProvider.useAuth()
|
||||
const { accessToken, user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
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 (
|
||||
<Modal hidden={hidden} className="absolute overflow-hidden bg-dim w-full h-full">
|
||||
<div
|
||||
@ -70,15 +62,6 @@ export default function UserMenu(props: UserMenuProps) {
|
||||
<span className="leading-170 h-6 py-px">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{canChangePassword && (
|
||||
<MenuEntry
|
||||
action={shortcutManager.KeyboardAction.changeYourPassword}
|
||||
paddingClassName="p-1"
|
||||
doAction={() => {
|
||||
setModal(<ChangePasswordModal />)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!supportsLocalBackend && (
|
||||
<MenuEntry
|
||||
action={shortcutManager.KeyboardAction.downloadApp}
|
||||
|
@ -8,7 +8,6 @@ import AddNetworkIcon from 'enso-assets/add_network.svg'
|
||||
import AppDownloadIcon from 'enso-assets/app_download.svg'
|
||||
import BlankIcon from 'enso-assets/blank_16.svg'
|
||||
import CameraIcon from 'enso-assets/camera.svg'
|
||||
import ChangePasswordIcon from 'enso-assets/change_password.svg'
|
||||
import CloseIcon from 'enso-assets/close.svg'
|
||||
import CloudToIcon from 'enso-assets/cloud_to.svg'
|
||||
import CopyIcon from 'enso-assets/copy.svg'
|
||||
@ -87,7 +86,6 @@ export enum KeyboardAction {
|
||||
newDataLink = 'new-data-link',
|
||||
closeModal = 'close-modal',
|
||||
cancelEditName = 'cancel-edit-name',
|
||||
changeYourPassword = 'change-your-password',
|
||||
signIn = 'sign-in',
|
||||
signOut = 'sign-out',
|
||||
downloadApp = 'download-app',
|
||||
@ -471,7 +469,6 @@ const DEFAULT_KEYBOARD_SHORTCUTS: Readonly<Record<KeyboardAction, KeyboardShortc
|
||||
],
|
||||
[KeyboardAction.closeModal]: [keybind(KeyboardAction.closeModal, [], 'Escape')],
|
||||
[KeyboardAction.cancelEditName]: [keybind(KeyboardAction.cancelEditName, [], 'Escape')],
|
||||
[KeyboardAction.changeYourPassword]: [],
|
||||
[KeyboardAction.signIn]: [],
|
||||
[KeyboardAction.signOut]: [],
|
||||
[KeyboardAction.downloadApp]: [],
|
||||
@ -520,7 +517,6 @@ const DEFAULT_KEYBOARD_SHORTCUT_INFO: Readonly<Record<KeyboardAction, ShortcutIn
|
||||
// These should not appear in any context menus.
|
||||
[KeyboardAction.closeModal]: { name: 'Close', icon: BlankIcon },
|
||||
[KeyboardAction.cancelEditName]: { name: 'Cancel Editing', icon: BlankIcon },
|
||||
[KeyboardAction.changeYourPassword]: { name: 'Change Your Password', icon: ChangePasswordIcon },
|
||||
[KeyboardAction.signIn]: { name: 'Login', icon: SignInIcon },
|
||||
[KeyboardAction.signOut]: { name: 'Logout', icon: SignOutIcon, colorClass: 'text-delete' },
|
||||
[KeyboardAction.downloadApp]: { name: 'Download App', icon: AppDownloadIcon },
|
||||
|
@ -22,6 +22,7 @@ export const PASSWORD_PATTERN =
|
||||
export const PASSWORD_ERROR =
|
||||
'Your password must include numbers, letters (both lowercase and uppercase) and symbols, ' +
|
||||
'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.'
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user