mirror of
https://github.com/enso-org/enso.git
synced 2024-11-27 06:32:30 +03:00
Add settings page; add endpoints for deleting user and uploading profile picture (#8720)
- Depends on unmerged backend features: - https://github.com/enso-org/cloud-v2/issues/832 - https://github.com/enso-org/cloud-v2/issues/833 - Close https://github.com/enso-org/cloud-v2/issues/829 - Add scaffold for settings page - Add endpoint for deleting user - Add endpoint for uploading profile picture - Modify API types to add profile picture to user info - Add shortcuts: - <kbd>Cmd</kbd>+<kbd>,</kbd> to open settings page (shortcut taken from VS Code) - <kbd>Esc</kbd> to close settings page - ℹ️ Note that, while the settings page is considered as a page by the logic, it is not saved for the next session, as the settings page should be a transitional page (it should only ever be open to do one specific task, and then immediately closed again) - Partly implement https://github.com/enso-org/cloud-v2/issues/840 - Add "members" tab with member list - Add "Invite Users" button to be able to invite multiple users - Misc. QoL improvements - Deselect selections when clicking away from the selection # Important Notes None
This commit is contained in:
parent
3a849ea01b
commit
6e7672a424
5
app/ide-desktop/lib/assets/bell.svg
Normal file
5
app/ide-desktop/lib/assets/bell.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M10 2C10 2.02997 9.99934 2.05979 9.99804 2.08944C11.7151 2.46727 13 3.9978 13 5.82843V6.64298C13 8.15215 13.5995 9.59952 14.6667 10.6667C14.8801 10.8801 15 11.1696 15 11.4714V12C15 12.5523 14.5523 13 14 13H2C1.44772 13 1 12.5523 1 12V11.4714C1 11.1696 1.1199 10.8801 1.33333 10.6667C2.40048 9.59952 3 8.15215 3 6.64298V5.82843C3 3.9978 4.28486 2.46727 6.00196 2.08944C6.00066 2.05979 6 2.02997 6 2C6 0.895431 6.89543 0 8 0C9.10457 0 10 0.895431 10 2ZM9.84776 14.7654C9.94827 14.5227 10 14.2626 10 14H8H6C6 14.2626 6.05173 14.5227 6.15224 14.7654C6.25275 15.008 6.40007 15.2285 6.58579 15.4142C6.7715 15.5999 6.99198 15.7472 7.23463 15.8478C7.47728 15.9483 7.73736 16 8 16C8.26264 16 8.52272 15.9483 8.76537 15.8478C9.00802 15.7472 9.2285 15.5999 9.41421 15.4142C9.59993 15.2285 9.74725 15.008 9.84776 14.7654Z"
|
||||
fill="black" />
|
||||
</svg>
|
After Width: | Height: | Size: 998 B |
11
app/ide-desktop/lib/assets/sliders.svg
Normal file
11
app/ide-desktop/lib/assets/sliders.svg
Normal file
@ -0,0 +1,11 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="2" width="5" height="2" fill="black" />
|
||||
<rect x="10" y="2" width="5" height="2" fill="black" />
|
||||
<rect x="12" y="7" width="3" height="2" fill="black" />
|
||||
<rect x="1" y="7" width="7" height="2" fill="black" />
|
||||
<rect x="1" y="12" width="3" height="2" fill="black" />
|
||||
<rect x="8" y="12" width="7" height="2" fill="black" />
|
||||
<rect x="6" y="0.5" width="2" height="5" fill="black" />
|
||||
<rect x="10" y="5.5" width="2" height="5" fill="black" />
|
||||
<rect x="4" y="10.5" width="2" height="5" fill="black" />
|
||||
</svg>
|
After Width: | Height: | Size: 645 B |
@ -26,7 +26,7 @@ export const COMPANY_NAME = 'New Byte Order sp. z o.o.'
|
||||
* These are required to increase the resolution of `performance.now()` timers,
|
||||
* making profiling a lot more accurate and consistent. */
|
||||
export const COOP_COEP_CORP_HEADERS: [header: string, value: string][] = [
|
||||
['Cross-Origin-Embedder-Policy', 'require-corp'],
|
||||
['Cross-Origin-Embedder-Policy', 'credentialless'],
|
||||
['Cross-Origin-Opener-Policy', 'same-origin'],
|
||||
['Cross-Origin-Resource-Policy', 'same-origin'],
|
||||
]
|
||||
|
@ -49,7 +49,7 @@ if (detect.IS_DEV_MODE && !detect.isOnElectron()) {
|
||||
})
|
||||
}
|
||||
void (async () => {
|
||||
// `navigator.serviceWorker` may be disabled in certainsituations, for example in Private mode
|
||||
// `navigator.serviceWorker` may be disabled in certain situations, for example in Private mode
|
||||
// on Safari.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const registration = await navigator.serviceWorker?.getRegistration()
|
||||
|
@ -16,7 +16,6 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="
|
||||
default-src 'self';
|
||||
frame-src 'self' data: https://accounts.google.com https://enso-org.firebaseapp.com;
|
||||
script-src 'self' 'unsafe-eval' data: https://*;
|
||||
style-src 'self' 'unsafe-inline' data: https://*;
|
||||
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
|
||||
|
@ -174,6 +174,32 @@ function AppRouter(props: AppProps) {
|
||||
: // This is safe, because the backend is always set by the authentication flow.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
null!
|
||||
React.useEffect(() => {
|
||||
let isClick = false
|
||||
const onMouseDown = () => {
|
||||
isClick = true
|
||||
}
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
if (
|
||||
isClick &&
|
||||
!(event.target instanceof HTMLInputElement) &&
|
||||
!(event.target instanceof HTMLTextAreaElement)
|
||||
) {
|
||||
document.getSelection()?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
const onSelectStart = () => {
|
||||
isClick = false
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('selectstart', onSelectStart)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
}
|
||||
}, [])
|
||||
const routes = (
|
||||
<router.Routes>
|
||||
<React.Fragment>
|
||||
|
@ -1,11 +1,8 @@
|
||||
/** @file Modal for confirming delete of any type of asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastify from 'react-toastify'
|
||||
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
@ -23,7 +20,7 @@ export interface ConfirmDeleteModalProps {
|
||||
/** A modal for confirming the deletion of an asset. */
|
||||
export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
const { description, doDelete } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const onSubmit = () => {
|
||||
@ -31,9 +28,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
try {
|
||||
doDelete()
|
||||
} catch (error) {
|
||||
const message = errorModule.getMessageOrToString(error)
|
||||
toastify.toast.error(message)
|
||||
logger.error(message)
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -36,10 +36,10 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
const { item, setItem, state } = props
|
||||
const { category, dispatchAssetEvent } = state
|
||||
const asset = item.item
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const self = asset.permissions?.find(
|
||||
permission => permission.user.user_email === session.organization?.email
|
||||
permission => permission.user.user_email === organization?.email
|
||||
)
|
||||
const managesThisAsset =
|
||||
category !== Category.trash &&
|
||||
@ -57,7 +57,7 @@ export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
|
||||
[/* should never change */ setItem]
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="group flex items-center gap-1">
|
||||
{(asset.permissions ?? []).map(user => (
|
||||
<PermissionDisplay key={user.user.pk} action={user.permission}>
|
||||
{user.user.user_name}
|
||||
|
@ -33,6 +33,7 @@ export interface AssetSettingsPanelRequiredProps {
|
||||
export interface AssetSettingsPanelProps extends AssetSettingsPanelRequiredProps {
|
||||
supportsLocalBackend: boolean
|
||||
page: pageSwitcher.Page
|
||||
setPage: (page: pageSwitcher.Page) => void
|
||||
category: Category
|
||||
isHelpChatOpen: boolean
|
||||
setIsHelpChatOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
@ -46,8 +47,8 @@ export interface AssetSettingsPanelProps extends AssetSettingsPanelRequiredProps
|
||||
|
||||
/** A panel containing the description and settings for an asset. */
|
||||
export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
const { item: rawItem, setItem: rawSetItem, supportsLocalBackend, page, category } = props
|
||||
const { isHelpChatOpen, setIsHelpChatOpen, setIsSettingsPanelVisible } = props
|
||||
const { item: rawItem, setItem: rawSetItem, supportsLocalBackend, page, setPage } = props
|
||||
const { category, isHelpChatOpen, setIsHelpChatOpen, setIsSettingsPanelVisible } = props
|
||||
const { dispatchAssetEvent, projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
|
||||
const [item, innerSetItem] = React.useState(rawItem)
|
||||
const [isEditingDescription, setIsEditingDescription] = React.useState(false)
|
||||
@ -116,6 +117,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
onSignOut={onSignOut}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
projectAsset={projectAsset}
|
||||
setProjectAsset={setProjectAsset}
|
||||
doRemoveSelf={doRemoveSelf}
|
||||
|
@ -10,7 +10,7 @@ import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/assetSearchBar'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import AssetsTableContextMenu from '#/layouts/dashboard/AssetsTableContextMenu'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
@ -325,8 +325,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { deletedLabelNames, initialProjectName, projectStartupInfo } = props
|
||||
const { queuedAssetEvents: rawQueuedAssetEvents } = props
|
||||
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
|
||||
const { setAssetSettingsPanelProps, doOpenIde, doCloseIde: rawDoCloseIde } = props
|
||||
const { doCreateLabel } = props
|
||||
const { setAssetSettingsPanelProps, doOpenIde, doCloseIde: rawDoCloseIde, doCreateLabel } = props
|
||||
|
||||
const { organization, user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
@ -0,0 +1,66 @@
|
||||
/** @file Modal for confirming delete of any type of asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
// ==============================
|
||||
// === ConfirmDeleteUserModal ===
|
||||
// ==============================
|
||||
|
||||
/** A modal for confirming the deletion of a user. */
|
||||
export default function ConfirmDeleteUserModal() {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { signOut } = authProvider.useAuth()
|
||||
|
||||
const onSubmit = async () => {
|
||||
unsetModal()
|
||||
try {
|
||||
await backend.deleteUser()
|
||||
await signOut()
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal centered className="bg-dim">
|
||||
<form
|
||||
data-testid="confirm-delete-modal"
|
||||
ref={element => {
|
||||
element?.focus()
|
||||
}}
|
||||
tabIndex={-1}
|
||||
className="relative flex flex-col items-center gap-2 rounded-2xl w-96 px-4 p-2 pointer-events-auto before:absolute before:inset-0 before:rounded-2xl before:bg-frame-selected before:backdrop-blur-3xl before:w-full before:h-full"
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
void onSubmit()
|
||||
}}
|
||||
>
|
||||
<h3 className="relative font-bold text-xl h-9.5 py-0.5">Are you sure?</h3>
|
||||
<div className="relative flex flex-col gap-2">
|
||||
Once deleted, this user account will be gone forever.
|
||||
<button type="submit" className="rounded-full bg-danger text-inversed px-2 py-1">
|
||||
<span className="leading-5 h-6 py-px">
|
||||
I confirm that I want to delete this user account.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -10,7 +10,7 @@ import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/assetSearchBar'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import AssetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
import CategorySwitcher from '#/layouts/dashboard/CategorySwitcher'
|
||||
@ -198,9 +198,9 @@ export default function Drive(props: DriveProps) {
|
||||
type: AssetListEventType.newProject,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
templateId: templateId,
|
||||
templateName: templateName,
|
||||
onSpinnerStateChange: onSpinnerStateChange,
|
||||
templateId,
|
||||
templateName,
|
||||
onSpinnerStateChange,
|
||||
})
|
||||
},
|
||||
[rootDirectoryId, /* should never change */ dispatchAssetListEvent]
|
||||
|
@ -0,0 +1,167 @@
|
||||
/** @file A modal with inputs for user email and permission level. */
|
||||
import * as React from 'react'
|
||||
|
||||
import isEmail from 'validator/es/lib/isEmail'
|
||||
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
// ==============================
|
||||
// === ManagePermissionsModal ===
|
||||
// ==============================
|
||||
|
||||
/** Props for an {@link InviteUsersModal}. */
|
||||
export interface InviteUsersModalProps {
|
||||
/** If this is `null`, this modal will be centered. */
|
||||
eventTarget: HTMLElement | null
|
||||
}
|
||||
|
||||
/** A modal for inviting one or more users. */
|
||||
export default function InviteUsersModal(props: InviteUsersModalProps) {
|
||||
const { eventTarget } = props
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [newEmails, setNewEmails] = React.useState(new Set<string>())
|
||||
const [email, setEmail] = React.useState<string>('')
|
||||
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
|
||||
const members = asyncEffectHooks.useAsyncEffect([], () => backend.listUsers(), [backend])
|
||||
const existingEmails = React.useMemo(
|
||||
() => new Set(members.map<string>(member => member.email)),
|
||||
[members]
|
||||
)
|
||||
const invalidEmailError = React.useMemo(
|
||||
() =>
|
||||
email === ''
|
||||
? 'Email is blank'
|
||||
: !isEmail(email)
|
||||
? `'${email}' is not a valid email`
|
||||
: existingEmails.has(email)
|
||||
? `'${email}' is already in the organization`
|
||||
: newEmails.has(email)
|
||||
? `You are already adding '${email}'`
|
||||
: null,
|
||||
[email, existingEmails, newEmails]
|
||||
)
|
||||
const isEmailValid = invalidEmailError == null
|
||||
|
||||
const doAddEmail = () => {
|
||||
if (!isEmailValid) {
|
||||
toastAndLog(invalidEmailError)
|
||||
} else {
|
||||
setNewEmails(oldNewEmails => new Set([...oldNewEmails, email]))
|
||||
setEmail('')
|
||||
}
|
||||
}
|
||||
|
||||
const doSubmit = () => {
|
||||
unsetModal()
|
||||
if (organization != null) {
|
||||
for (const newEmail of newEmails) {
|
||||
void (async () => {
|
||||
try {
|
||||
await backend.inviteUser({
|
||||
organizationId: organization.id,
|
||||
userEmail: backendModule.EmailAddress(newEmail),
|
||||
})
|
||||
} catch (error) {
|
||||
toastAndLog(`Could not invite user '${newEmail}'`, error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered={eventTarget == null}
|
||||
className="absolute overflow-hidden bg-dim w-full h-full top-0 left-0"
|
||||
>
|
||||
<div
|
||||
tabIndex={-1}
|
||||
style={
|
||||
position != null
|
||||
? {
|
||||
left: position.left + window.scrollX,
|
||||
top: position.top + window.scrollY,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
className="sticky w-115.25 rounded-2xl before:absolute before:bg-frame-selected before:backdrop-blur-3xl before:rounded-2xl before:w-full before:h-full"
|
||||
onClick={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
}}
|
||||
onContextMenu={mouseEvent => {
|
||||
mouseEvent.stopPropagation()
|
||||
mouseEvent.preventDefault()
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Escape') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col rounded-2xl gap-2 p-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold">Invite</h2>
|
||||
{/* Space reserved for other tabs. */}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-1"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
doAddEmail()
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center grow rounded-full border border-black/10 gap-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type email to invite"
|
||||
className="w-full bg-transparent"
|
||||
value={email}
|
||||
onInput={event => {
|
||||
setEmail(event.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isEmailValid}
|
||||
{...(!isEmailValid ? { title: invalidEmailError } : {})}
|
||||
className="text-tag-text bg-invite rounded-full whitespace-nowrap px-4 py-1 disabled:opacity-30"
|
||||
>
|
||||
<div className="h-6 py-0.5">Add User</div>
|
||||
</button>
|
||||
</form>
|
||||
<ul className="flex flex-col px-1">
|
||||
{[...newEmails].map(newEmail => (
|
||||
<li key={newEmail} className="h-6 leading-5 py-px">
|
||||
{newEmail}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="self-start">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isEmailValid && email !== ''}
|
||||
{...(!isEmailValid && email !== '' ? { title: invalidEmailError } : {})}
|
||||
className="text-tag-text bg-invite rounded-full px-4 py-1 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
doSubmit()
|
||||
}}
|
||||
>
|
||||
<div className="h-6 py-0.5">Invite All</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
@ -139,10 +139,10 @@ export default function ManagePermissionsModal<
|
||||
organizationId: organization.id,
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
})
|
||||
toast.toast.success(`You've invited ${email} to join Enso!`)
|
||||
toast.toast.success(`You've invited '${email}' to join Enso!`)
|
||||
}
|
||||
} catch (error) {
|
||||
toastAndLog('Could not invite user', error)
|
||||
toastAndLog(`Could not invite user '${email}'`, error)
|
||||
}
|
||||
} else {
|
||||
setUsers([])
|
||||
|
@ -16,6 +16,7 @@ export enum Page {
|
||||
home = 'home',
|
||||
drive = 'drive',
|
||||
editor = 'editor',
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
/** Error text for each page. */
|
||||
@ -23,6 +24,7 @@ const ERRORS: Record<Page, string | null> = {
|
||||
[Page.home]: null,
|
||||
[Page.drive]: null,
|
||||
[Page.editor]: 'No project is currently open.',
|
||||
[Page.settings]: null,
|
||||
}
|
||||
|
||||
/** Data describing how to display a button for a pageg. */
|
||||
|
@ -0,0 +1,50 @@
|
||||
/** @file Settings screen. */
|
||||
import * as React from 'react'
|
||||
|
||||
import SettingsSidebar from '#/layouts/dashboard/SettingsSidebar'
|
||||
import AccountSettingsTab from '#/layouts/dashboard/settingsTab/AccountSettingsTab'
|
||||
import MembersSettingsTab from '#/layouts/dashboard/settingsTab/MembersSettingsTab'
|
||||
import SettingsTab from '#/layouts/dashboard/settingsTab/SettingsTab'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
|
||||
// ================
|
||||
// === Settings ===
|
||||
// ================
|
||||
|
||||
/** Settings screen. */
|
||||
export default function Settings() {
|
||||
const [settingsTab, setSettingsTab] = React.useState(SettingsTab.account)
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
|
||||
let content: JSX.Element
|
||||
switch (settingsTab) {
|
||||
case SettingsTab.account: {
|
||||
content = <AccountSettingsTab />
|
||||
break
|
||||
}
|
||||
case SettingsTab.members: {
|
||||
content = <MembersSettingsTab />
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// This case should be removed when all settings tabs are implemented.
|
||||
content = <></>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex gap-2.5 font-bold text-xl h-9.5 px-4.75">
|
||||
<span className="py-0.5">Settings for </span>
|
||||
<div className="rounded-full leading-144.5 bg-frame h-9 px-2.25 pt-0.5 pb-1.25">
|
||||
{organization?.name ?? '(Unknown Organization)'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 pl-3">
|
||||
<SettingsSidebar settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
/** @file A panel to switch between settings tabs. */
|
||||
import * as React from 'react'
|
||||
|
||||
import BellIcon from 'enso-assets/bell.svg'
|
||||
import PeopleIcon from 'enso-assets/people.svg'
|
||||
import SettingsIcon from 'enso-assets/settings.svg'
|
||||
import SlidersIcon from 'enso-assets/sliders.svg'
|
||||
|
||||
import SettingsTab from '#/layouts/dashboard/settingsTab/SettingsTab'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const SECTIONS: SettingsSectionData[] = [
|
||||
{
|
||||
name: 'General',
|
||||
tabs: [
|
||||
{
|
||||
name: 'Account',
|
||||
settingsTab: SettingsTab.account,
|
||||
icon: SettingsIcon,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
name: 'Features',
|
||||
settingsTab: SettingsTab.features,
|
||||
icon: SlidersIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
settingsTab: SettingsTab.notifications,
|
||||
icon: BellIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Access',
|
||||
tabs: [
|
||||
{
|
||||
name: 'Members',
|
||||
settingsTab: SettingsTab.members,
|
||||
icon: PeopleIcon,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Metadata for rendering a settings tab label. */
|
||||
interface SettingsTabLabelData {
|
||||
name: string
|
||||
settingsTab: SettingsTab
|
||||
icon: string
|
||||
/** Temporary, until all tabs are implemented. */
|
||||
visible?: true
|
||||
}
|
||||
|
||||
/** Metadata for rendering a settings section. */
|
||||
interface SettingsSectionData {
|
||||
name: string
|
||||
tabs: SettingsTabLabelData[]
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === SettingsSidebar ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link SettingsSidebar} */
|
||||
export interface SettingsSidebarProps {
|
||||
settingsTab: SettingsTab
|
||||
setSettingsTab: React.Dispatch<React.SetStateAction<SettingsTab>>
|
||||
}
|
||||
|
||||
/** A panel to switch between settings tabs. */
|
||||
export default function SettingsSidebar(props: SettingsSidebarProps) {
|
||||
const { settingsTab, setSettingsTab } = props
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-51.5 overflow-y-auto">
|
||||
{SECTIONS.flatMap(section => {
|
||||
const tabs = section.tabs.filter(tab => tab.visible)
|
||||
return tabs.length === 0
|
||||
? []
|
||||
: [
|
||||
<div key={section.name} className="flex flex-col items-start">
|
||||
<h2 className="text-sm font-bold h-7.5 leading-5 pl-2 pt-0.5 pb-2">
|
||||
{section.name}
|
||||
</h2>
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.settingsTab}
|
||||
className={`flex items-center gap-2 h-8 px-2 rounded-full hover:text-primary hover:bg-frame-selected transition-colors ${
|
||||
tab.settingsTab === settingsTab
|
||||
? 'text-primary bg-frame-selected'
|
||||
: 'cursor-pointer text-not-selected'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSettingsTab(tab.settingsTab)
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={tab.icon}
|
||||
className={
|
||||
tab.settingsTab === settingsTab
|
||||
? 'text-icon-selected'
|
||||
: 'text-icon-not-selected'
|
||||
}
|
||||
/>
|
||||
<span className="h-6 leading-5 py-px">{tab.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
]
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
/** @file The top-bar of dashboard. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/assetSearchBar'
|
||||
import AssetSearchBar from '#/layouts/dashboard/assetSearchBar'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import AssetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import BackendSwitcher from '#/layouts/dashboard/BackendSwitcher'
|
||||
import PageSwitcher, * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import UserBar from '#/layouts/dashboard/UserBar'
|
||||
@ -78,6 +78,7 @@ export default function TopBar(props: TopBarProps) {
|
||||
<UserBar
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
isHelpChatOpen={isHelpChatOpen}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
projectAsset={projectAsset}
|
||||
|
@ -22,6 +22,7 @@ import Button from '#/components/Button'
|
||||
export interface UserBarProps {
|
||||
supportsLocalBackend: boolean
|
||||
page: pageSwitcher.Page
|
||||
setPage: (page: pageSwitcher.Page) => void
|
||||
isHelpChatOpen: boolean
|
||||
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
|
||||
projectAsset: backendModule.ProjectAsset | null
|
||||
@ -32,7 +33,7 @@ export interface UserBarProps {
|
||||
|
||||
/** A toolbar containing chat and the user menu. */
|
||||
export default function UserBar(props: UserBarProps) {
|
||||
const { supportsLocalBackend, page, isHelpChatOpen, setIsHelpChatOpen } = props
|
||||
const { supportsLocalBackend, page, setPage, isHelpChatOpen, setIsHelpChatOpen } = props
|
||||
const { projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, updateModal } = modalProvider.useSetModal()
|
||||
@ -78,17 +79,22 @@ export default function UserBar(props: UserBarProps) {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center rounded-full overflow-clip w-7.25 h-7.25"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
updateModal(oldModal =>
|
||||
oldModal?.type === UserMenu ? null : (
|
||||
<UserMenu supportsLocalBackend={supportsLocalBackend} onSignOut={onSignOut} />
|
||||
<UserMenu
|
||||
setPage={setPage}
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={DefaultUserIcon}
|
||||
src={organization?.profilePicture ?? DefaultUserIcon}
|
||||
alt="Open user menu"
|
||||
height={28}
|
||||
width={28}
|
||||
@ -97,6 +103,14 @@ export default function UserBar(props: UserBarProps) {
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{/* Required for shortcuts to work. */}
|
||||
<div className="hidden">
|
||||
<UserMenu
|
||||
setPage={setPage}
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import * as appUtils from '#/appUtils'
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import ChangePasswordModal from '#/layouts/dashboard/ChangePasswordModal'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as github from '#/utilities/github'
|
||||
@ -21,13 +22,14 @@ import Modal from '#/components/Modal'
|
||||
|
||||
/** Props for a {@link UserMenu}. */
|
||||
export interface UserMenuProps {
|
||||
setPage: (page: pageSwitcher.Page) => void
|
||||
supportsLocalBackend: boolean
|
||||
onSignOut: () => void
|
||||
}
|
||||
|
||||
/** Handling the UserMenuItem click event logic and displaying its content. */
|
||||
export default function UserMenu(props: UserMenuProps) {
|
||||
const { supportsLocalBackend, onSignOut } = props
|
||||
const { setPage, supportsLocalBackend, onSignOut } = props
|
||||
const navigate = navigateHooks.useNavigate()
|
||||
const { signOut } = authProvider.useAuth()
|
||||
const { accessToken, organization } = authProvider.useNonPartialUserSession()
|
||||
@ -53,7 +55,9 @@ export default function UserMenu(props: UserMenuProps) {
|
||||
{organization != null ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<img src={DefaultUserIcon} height={28} width={28} />
|
||||
<div className="flex items-center rounded-full overflow-clip w-7.25 h-7.25">
|
||||
<img src={organization.profilePicture ?? DefaultUserIcon} height={28} width={28} />
|
||||
</div>
|
||||
<span className="leading-170 h-6 py-px">{organization.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@ -81,6 +85,14 @@ export default function UserMenu(props: UserMenuProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuEntry
|
||||
action={shortcuts.KeyboardAction.settings}
|
||||
paddingClassName="p-1"
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
setPage(pageSwitcher.Page.settings)
|
||||
}}
|
||||
/>
|
||||
<MenuEntry
|
||||
action={shortcuts.KeyboardAction.signOut}
|
||||
paddingClassName="p-1"
|
||||
|
@ -0,0 +1,179 @@
|
||||
/** @file Settings tab for viewing and editing account information. */
|
||||
import * as React from 'react'
|
||||
|
||||
import DefaultUserIcon from 'enso-assets/default_user.svg'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import ConfirmDeleteUserModal from '../ConfirmDeleteUserModal'
|
||||
|
||||
// =================
|
||||
// === InfoEntry ===
|
||||
// =================
|
||||
|
||||
/** Props for a transparent wrapper component. */
|
||||
interface InternalTransparentWrapperProps {
|
||||
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 {
|
||||
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-12 h-8 py-1.25">{name}</span>
|
||||
<span className="grow font-bold leading-5 h-8 py-1.25">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === AccountSettingsTab ===
|
||||
// ==========================
|
||||
|
||||
/** Settings tab for viewing and editing account information. */
|
||||
export default function AccountSettingsTab() {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setOrganization } = authProvider.useAuth()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const doUpdateName = async () => {
|
||||
const oldName = organization?.name ?? ''
|
||||
const newName = nameInputRef.current?.value ?? ''
|
||||
if (newName === oldName) {
|
||||
return
|
||||
} else {
|
||||
try {
|
||||
await backend.updateUser({ username: newName })
|
||||
setOrganization(object.merger({ name: newName }))
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
if (nameInputRef.current) {
|
||||
nameInputRef.current.value = organization?.name ?? ''
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const doUploadUserPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const image = event.target.files?.[0]
|
||||
if (image == null) {
|
||||
toastAndLog('Could not upload a new profile picture because no image was found')
|
||||
} else {
|
||||
try {
|
||||
const newUser = await backend.uploadUserPicture({ fileName: image.name }, image)
|
||||
setOrganization(newUser)
|
||||
} catch (error) {
|
||||
toastAndLog(null, error)
|
||||
}
|
||||
}
|
||||
// Reset selected files, otherwise the file input will do nothing if the same file is
|
||||
// selected again. While technically not undesired behavior, it is unintuitive for the user.
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
<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={organization?.name}
|
||||
type="text"
|
||||
size={1}
|
||||
defaultValue={organization?.name ?? ''}
|
||||
onBlur={doUpdateName}
|
||||
onKeyDown={event => {
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
event.stopPropagation()
|
||||
event.currentTarget.value = organization?.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>{organization?.email ?? ''}</Value>
|
||||
</InfoEntry>
|
||||
</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">
|
||||
<button
|
||||
className="rounded-full bg-danger text-inversed px-2 py-1"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setModal(<ConfirmDeleteUserModal />)
|
||||
}}
|
||||
>
|
||||
<span className="leading-5 h-6 py-px">Delete this user account</span>
|
||||
</button>
|
||||
<span className="leading-5 h-8 py-1.25">
|
||||
Once deleted, it will be gone forever. Please be certain.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h3 className="font-bold text-xl h-9.5 py-0.5">Profile picture</h3>
|
||||
<label className="flex items-center cursor-pointer rounded-full overflow-clip h-32 w-32 hover:bg-frame transition-colors">
|
||||
<input type="file" className="hidden" accept="image/*" onChange={doUploadUserPicture} />
|
||||
<img src={organization?.profilePicture ?? DefaultUserIcon} width={128} height={128} />
|
||||
</label>
|
||||
<span className="py-1 w-64">
|
||||
Your organization's profile picture should not be irrelevant, abusive or vulgar. It
|
||||
should not be a default image provided by Enso.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
/** @file Settings tab for viewing and editing account information. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import InviteUsersModal from '#/layouts/dashboard/InviteUsersModal'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
// ==========================
|
||||
// === MembersSettingsTab ===
|
||||
// ==========================
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function MembersSettingsTab() {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const members = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [backend])
|
||||
const isLoading = members == null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h3 className="font-bold text-xl h-9.5 py-0.5">Members</h3>
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
className="flex items-center bg-frame rounded-full h-8 px-2.5"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setModal(<InviteUsersModal eventTarget={null} />)
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold whitespace-nowrap leading-5 h-6 py-px">
|
||||
Invite Members
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<table className="self-start table-fixed">
|
||||
<thead>
|
||||
<tr className="h-8">
|
||||
<th className="text-left bg-clip-padding border-transparent border-l-2 border-r-2 last:border-r-0 text-sm font-semibold w-32">
|
||||
Name
|
||||
</th>
|
||||
<th className="text-left bg-clip-padding border-transparent border-l-2 border-r-2 last:border-r-0 text-sm font-semibold w-48">
|
||||
Email
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="select-text">
|
||||
{isLoading ? (
|
||||
<tr className="h-8">
|
||||
<td colSpan={2}>
|
||||
<div className="flex justify-center">
|
||||
<StatelessSpinner
|
||||
size={32}
|
||||
state={statelessSpinner.SpinnerState.loadingMedium}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
members.map(member => (
|
||||
<tr key={member.id} className="h-8">
|
||||
<td>{member.name}</td>
|
||||
<td>{member.email}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
/** @file A sub-page of the settings page. */
|
||||
|
||||
// ===================
|
||||
// === SettingsTab ===
|
||||
// ===================
|
||||
|
||||
/** A sub-page of the settings page. */
|
||||
enum SettingsTab {
|
||||
account = 'account',
|
||||
features = 'features',
|
||||
notifications = 'notifications',
|
||||
billingAndPlans = 'billing-and-plans',
|
||||
members = 'members',
|
||||
memberRoles = 'member-roles',
|
||||
appearance = 'appearance',
|
||||
keyboardShortcuts = 'keyboard-shortcuts',
|
||||
dataCoPilot = 'data-co-pilot',
|
||||
featurePreview = 'feature-preview',
|
||||
activityLog = 'activity-log',
|
||||
compliance = 'compliance',
|
||||
usageStatistics = 'usage-statistics',
|
||||
personalAccessToken = 'personal-access-token',
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export default SettingsTab
|
@ -7,7 +7,7 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/assetSearchBar'
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import AssetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
@ -17,6 +17,7 @@ import Drive from '#/layouts/dashboard/Drive'
|
||||
import Editor from '#/layouts/dashboard/Editor'
|
||||
import Home from '#/layouts/dashboard/Home'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import Settings from '#/layouts/dashboard/Settings'
|
||||
import TopBar from '#/layouts/dashboard/TopBar'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
@ -58,7 +59,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setBackend } = backendProvider.useSetBackend()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { updateModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
@ -231,7 +232,9 @@ export default function Dashboard(props: DashboardProps) {
|
||||
}, [isAssetSettingsPanelVisible, /* should never change */ localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.set(localStorageModule.LocalStorageKey.page, page)
|
||||
if (page !== pageSwitcher.Page.settings) {
|
||||
localStorage.set(localStorageModule.LocalStorageKey.page, page)
|
||||
}
|
||||
}, [page, /* should never change */ localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -265,14 +268,37 @@ export default function Dashboard(props: DashboardProps) {
|
||||
React.useEffect(() => {
|
||||
return shortcuts.registerKeyboardHandlers({
|
||||
[shortcutsModule.KeyboardAction.closeModal]: () => {
|
||||
unsetModal()
|
||||
updateModal(oldModal => {
|
||||
if (oldModal == null) {
|
||||
queueMicrotask(() => {
|
||||
setPage(oldPage => {
|
||||
if (oldPage !== pageSwitcher.Page.settings) {
|
||||
return oldPage
|
||||
} else {
|
||||
return (
|
||||
localStorage.get(localStorageModule.LocalStorageKey.page) ??
|
||||
pageSwitcher.Page.drive
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
return oldModal
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
if (modalRef.current == null) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [shortcuts, /* should never change */ modalRef, /* should never change */ unsetModal])
|
||||
}, [
|
||||
shortcuts,
|
||||
/* should never change */ modalRef,
|
||||
/* should never change */ localStorage,
|
||||
/* should never change */ updateModal,
|
||||
])
|
||||
|
||||
const setBackendType = React.useCallback(
|
||||
(newBackendType: backendModule.BackendType) => {
|
||||
@ -430,6 +456,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
appRunner={appRunner}
|
||||
/>
|
||||
{page === pageSwitcher.Page.settings && <Settings />}
|
||||
{/* `session.accessToken` MUST be present in order for the `Chat` component to work. */}
|
||||
{isHelpChatVisible && session.accessToken != null ? (
|
||||
<Chat
|
||||
@ -460,6 +487,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
key={assetSettingsPanelProps.item.item.id}
|
||||
{...assetSettingsPanelProps}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
category={Category.home}
|
||||
isHelpChatOpen={isHelpChatOpen}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
|
@ -26,6 +26,7 @@ import * as remoteBackend from '#/services/remoteBackend'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
import * as http from '#/utilities/http'
|
||||
import * as localStorageModule from '#/utilities/localStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -134,6 +135,7 @@ interface AuthContextType {
|
||||
*
|
||||
* If the user has not signed in, the session will be `null`. */
|
||||
session: UserSession | null
|
||||
setOrganization: React.Dispatch<React.SetStateAction<backendModule.UserOrOrganization>>
|
||||
}
|
||||
|
||||
// Eslint doesn't like headings.
|
||||
@ -196,6 +198,28 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
const [userSession, setUserSession] = React.useState<UserSession | null>(null)
|
||||
const toastId = React.useId()
|
||||
|
||||
const setOrganization = React.useCallback(
|
||||
(valueOrUpdater: React.SetStateAction<backendModule.UserOrOrganization>) => {
|
||||
setUserSession(oldUserSession => {
|
||||
if (
|
||||
oldUserSession == null ||
|
||||
!('organization' in oldUserSession) ||
|
||||
oldUserSession.organization == null
|
||||
) {
|
||||
return oldUserSession
|
||||
} else {
|
||||
return object.merge(oldUserSession, {
|
||||
organization:
|
||||
typeof valueOrUpdater !== 'function'
|
||||
? valueOrUpdater
|
||||
: valueOrUpdater(oldUserSession.organization),
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const goOfflineInternal = React.useCallback(() => {
|
||||
setInitialized(true)
|
||||
sentry.setUser(null)
|
||||
@ -311,12 +335,9 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
}
|
||||
break
|
||||
} catch (error) {
|
||||
if (
|
||||
// The value may have changed after the `await`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
!navigator.onLine ||
|
||||
isNetworkError(error)
|
||||
) {
|
||||
// The value may have changed after the `await`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!navigator.onLine || isNetworkError(error)) {
|
||||
void goOffline()
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
@ -589,6 +610,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
changePassword: withLoadingToast(changePassword),
|
||||
signOut,
|
||||
session: userSession,
|
||||
setOrganization,
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -90,6 +90,8 @@ export interface UserOrOrganization {
|
||||
id: UserOrOrganizationId
|
||||
name: string
|
||||
email: EmailAddress
|
||||
/** A URL. */
|
||||
profilePicture: string | null
|
||||
/** If `false`, this account is awaiting acceptance from an admin, and endpoints other than
|
||||
* `usersMe` will not work. */
|
||||
isEnabled: boolean
|
||||
@ -712,6 +714,11 @@ export interface CreateUserRequestBody {
|
||||
organizationId: UserOrOrganizationId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "update user" endpoint. */
|
||||
export interface UpdateUserRequestBody {
|
||||
username: string | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "invite user" endpoint. */
|
||||
export interface InviteUserRequestBody {
|
||||
organizationId: UserOrOrganizationId
|
||||
@ -797,6 +804,11 @@ export interface UploadFileRequestParams {
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "upload user profile picture" endpoint. */
|
||||
export interface UploadUserPictureRequestParams {
|
||||
fileName: string | null
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list versions" endpoint. */
|
||||
export interface ListVersionsRequestParams {
|
||||
versionType: VersionType
|
||||
@ -894,6 +906,15 @@ export abstract class Backend {
|
||||
abstract listUsers(): Promise<SimpleUser[]>
|
||||
/** Set the username of the current user. */
|
||||
abstract createUser(body: CreateUserRequestBody): Promise<UserOrOrganization>
|
||||
/** Change the username of the current user. */
|
||||
abstract updateUser(body: UpdateUserRequestBody): Promise<void>
|
||||
/** Delete the current user. */
|
||||
abstract deleteUser(): Promise<void>
|
||||
/** Upload a new profile picture for the current user. */
|
||||
abstract uploadUserPicture(
|
||||
params: UploadUserPictureRequestParams,
|
||||
file: Blob
|
||||
): Promise<UserOrOrganization>
|
||||
/** Invite a new user to the organization by email. */
|
||||
abstract inviteUser(body: InviteUserRequestBody): Promise<void>
|
||||
/** Adds a permission for a specific user on a specific asset. */
|
||||
@ -955,7 +976,7 @@ export abstract class Backend {
|
||||
/** Return a list of files accessible by the current user. */
|
||||
abstract listFiles(): Promise<File[]>
|
||||
/** Upload a file. */
|
||||
abstract uploadFile(params: UploadFileRequestParams, body: Blob): Promise<FileInfo>
|
||||
abstract uploadFile(params: UploadFileRequestParams, file: Blob): Promise<FileInfo>
|
||||
/** Return file details. */
|
||||
abstract getFileDetails(fileId: FileId, title: string | null): Promise<FileDetails>
|
||||
/** Create a secret environment variable. */
|
||||
|
@ -334,6 +334,21 @@ export class LocalBackend extends backend.Backend {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
|
||||
/** Invalid operation. */
|
||||
override updateUser() {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
|
||||
/** Invalid operation. */
|
||||
override deleteUser() {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
|
||||
/** Invalid operation. */
|
||||
override uploadUserPicture() {
|
||||
return this.invalidOperation()
|
||||
}
|
||||
|
||||
/** Do nothing. This function should never need to be called. */
|
||||
override inviteUser() {
|
||||
return Promise.resolve()
|
||||
|
@ -179,6 +179,31 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the username of the current user. */
|
||||
override async updateUser(body: backendModule.UpdateUserRequestBody): Promise<void> {
|
||||
const path = remoteBackendPaths.UPDATE_CURRENT_USER_PATH
|
||||
const response = await this.put(path, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
if (body.username != null) {
|
||||
return this.throw('Could not change username.')
|
||||
} else {
|
||||
return this.throw('Could not update user.')
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the current user. */
|
||||
override async deleteUser(): Promise<void> {
|
||||
const response = await this.delete(remoteBackendPaths.DELETE_USER_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Could not delete user.')
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Invite a new user to the organization by email. */
|
||||
override async inviteUser(body: backendModule.InviteUserRequestBody): Promise<void> {
|
||||
const path = remoteBackendPaths.INVITE_USER_PATH
|
||||
@ -190,6 +215,25 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload a new profile picture for the current user. */
|
||||
override async uploadUserPicture(
|
||||
params: backendModule.UploadUserPictureRequestParams,
|
||||
file: Blob
|
||||
): Promise<backendModule.UserOrOrganization> {
|
||||
const paramsString = new URLSearchParams({
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...(params.fileName != null ? { file_name: params.fileName } : {}),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}).toString()
|
||||
const path = `${remoteBackendPaths.UPLOAD_USER_PICTURE_PATH}?${paramsString}`
|
||||
const response = await this.postBinary<backendModule.UserOrOrganization>(path, file)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw('Could not upload user profile picture.')
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds a permission for a specific user on a specific asset. */
|
||||
override async createPermission(body: backendModule.CreatePermissionRequestBody): Promise<void> {
|
||||
const path = remoteBackendPaths.CREATE_PERMISSION_PATH
|
||||
@ -511,7 +555,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
override async uploadFile(
|
||||
params: backendModule.UploadFileRequestParams,
|
||||
body: Blob
|
||||
file: Blob
|
||||
): Promise<backendModule.FileInfo> {
|
||||
const paramsString = new URLSearchParams({
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
@ -521,7 +565,7 @@ export class RemoteBackend extends backendModule.Backend {
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}).toString()
|
||||
const path = `${remoteBackendPaths.UPLOAD_FILE_PATH}?${paramsString}`
|
||||
const response = await this.postBinary<backendModule.FileInfo>(path, body)
|
||||
const response = await this.postBinary<backendModule.FileInfo>(path, file)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
let suffix = '.'
|
||||
try {
|
||||
|
@ -7,8 +7,14 @@ import type * as backend from '#/services/backend'
|
||||
|
||||
/** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */
|
||||
export const LIST_USERS_PATH = 'users'
|
||||
/** Relative HTTP path to the "set username" endpoint of the Cloud backend API. */
|
||||
/** Relative HTTP path to the "create user" endpoint of the Cloud backend API. */
|
||||
export const CREATE_USER_PATH = 'users'
|
||||
/** Relative HTTP path to the "update current user" endpoint of the Cloud backend API. */
|
||||
export const UPDATE_CURRENT_USER_PATH = 'users/me'
|
||||
/** Relative HTTP path to the "delete user" endpoint of the Cloud backend API. */
|
||||
export const DELETE_USER_PATH = 'users/me'
|
||||
/** Relative HTTP path to the "upload user picture" endpoint of the Cloud backend API. */
|
||||
export const UPLOAD_USER_PICTURE_PATH = 'users/me/picture'
|
||||
/** Relative HTTP path to the "invite user" endpoint of the Cloud backend API. */
|
||||
export const INVITE_USER_PATH = 'users/invite'
|
||||
/** Relative HTTP path to the "create permission" endpoint of the Cloud backend API. */
|
||||
|
@ -20,6 +20,7 @@ import PenIcon from 'enso-assets/pen.svg'
|
||||
import PeopleIcon from 'enso-assets/people.svg'
|
||||
import Play2Icon from 'enso-assets/play2.svg'
|
||||
import ScissorsIcon from 'enso-assets/scissors.svg'
|
||||
import SettingsIcon from 'enso-assets/settings.svg'
|
||||
import SignInIcon from 'enso-assets/sign_in.svg'
|
||||
import SignOutIcon from 'enso-assets/sign_out.svg'
|
||||
import TagIcon from 'enso-assets/tag.svg'
|
||||
@ -51,6 +52,7 @@ export enum MouseAction {
|
||||
|
||||
/** All possible keyboard actions for which shortcuts can be registered. */
|
||||
export enum KeyboardAction {
|
||||
settings = 'settings',
|
||||
open = 'open',
|
||||
/** Run without opening the editor. */
|
||||
run = 'run',
|
||||
@ -424,6 +426,7 @@ const DELETE = detect.isOnMacOS() ? 'Backspace' : 'Delete'
|
||||
|
||||
/** The default keyboard shortcuts. */
|
||||
const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
|
||||
[KeyboardAction.settings]: [keybind(KeyboardAction.settings, [CTRL], ',')],
|
||||
[KeyboardAction.open]: [keybind(KeyboardAction.open, [], 'Enter')],
|
||||
[KeyboardAction.run]: [keybind(KeyboardAction.run, ['Shift'], 'Enter')],
|
||||
[KeyboardAction.close]: [],
|
||||
@ -463,6 +466,7 @@ const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
|
||||
|
||||
/** The default UI data for every keyboard shortcut. */
|
||||
const DEFAULT_KEYBOARD_SHORTCUT_INFO: Record<KeyboardAction, ShortcutInfo> = {
|
||||
[KeyboardAction.settings]: { name: 'Settings', icon: SettingsIcon },
|
||||
[KeyboardAction.open]: { name: 'Open', icon: OpenIcon },
|
||||
[KeyboardAction.run]: { name: 'Run', icon: Play2Icon },
|
||||
[KeyboardAction.close]: { name: 'Close', icon: CloseIcon },
|
||||
|
@ -30,6 +30,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
v3: '#252423',
|
||||
youtube: '#c62421',
|
||||
discord: '#404796',
|
||||
danger: '#d33b0b',
|
||||
dim: 'rgba(0, 0, 0, 0.25)',
|
||||
'dim-darker': 'rgba(0, 0, 0, 0.40)',
|
||||
frame: 'rgba(255, 255, 255, 0.40)',
|
||||
@ -60,6 +61,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
'4xl': '2.375rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'2.5xl': '1.25rem',
|
||||
'4xl': '2rem',
|
||||
},
|
||||
lineHeight: {
|
||||
@ -72,11 +74,14 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
1.75: '0.4375rem',
|
||||
2.25: '0.5625rem',
|
||||
3.25: '0.8125rem',
|
||||
3.75: '0.9375rem',
|
||||
3.5: '0.875rem',
|
||||
4.5: '1.125rem',
|
||||
4.75: '1.1875rem',
|
||||
5.5: '1.375rem',
|
||||
6.5: '1.625rem',
|
||||
7.25: '1.75rem',
|
||||
7.5: '1.875rem',
|
||||
9.5: '2.375rem',
|
||||
9.75: '2.4375rem',
|
||||
13: '3.25rem',
|
||||
@ -85,6 +90,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
29: '7.25rem',
|
||||
30: '7.5rem',
|
||||
30.25: '7.5625rem',
|
||||
38.25: '9.5625rem',
|
||||
42: '10.5rem',
|
||||
45: '11.25rem',
|
||||
51: '12.75rem',
|
||||
|
@ -42,6 +42,7 @@ export async function mockApi(page: test.Page) {
|
||||
email: defaultEmail,
|
||||
name: defaultUsername,
|
||||
id: defaultOrganizationId,
|
||||
profilePicture: null,
|
||||
isEnabled: true,
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
}
|
||||
@ -294,7 +295,8 @@ export async function mockApi(page: test.Page) {
|
||||
currentUser = {
|
||||
email: body.userEmail,
|
||||
name: body.userName,
|
||||
id,
|
||||
id: body.organizationId ?? defaultUser.id,
|
||||
profilePicture: null,
|
||||
isEnabled: false,
|
||||
rootDirectoryId,
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user