mirror of
https://github.com/enso-org/enso.git
synced 2024-12-26 04:24:28 +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')
|
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)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
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. */
|
/** 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 * 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">
|
||||||
|
@ -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>
|
||||||
|
@ -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}
|
||||||
|
@ -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 },
|
||||||
|
@ -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.'
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user