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:
somebody1234 2024-01-26 01:10:51 +10:00 committed by GitHub
parent 3a849ea01b
commit 6e7672a424
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 948 additions and 48 deletions

View 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

View 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

View File

@ -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'],
]

View File

@ -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()

View File

@ -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://*;

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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}

View File

@ -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}

View File

@ -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()

View File

@ -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>
)
}

View File

@ -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]

View File

@ -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>
)
}

View File

@ -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([])

View File

@ -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. */

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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}

View File

@ -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>
)
}

View File

@ -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"

View File

@ -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&apos;s profile picture should not be irrelevant, abusive or vulgar. It
should not be a default image provided by Enso.
</span>
</div>
</div>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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}

View File

@ -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 (

View File

@ -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. */

View File

@ -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()

View File

@ -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 {

View File

@ -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. */

View File

@ -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 },

View File

@ -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',

View File

@ -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,
}