mirror of
https://github.com/enso-org/enso.git
synced 2024-11-25 10:43:02 +03:00
Settings improvements (#10924)
- Address https://github.com/enso-org/cloud-v2/issues/1457
- Show x instead of a circle for close tab icon on Windows and Linux
- Free / Solo no longer shows Organization, Members, User Groups, and Activity Log
- Invite button should only be there for the admin in Team or above (should already be present)
- Should list the required symbols in the password change.
- Changing headshot appeared briefly and then disappeared.
- Fix https://github.com/enso-org/cloud-v2/issues/1455
- The `Alert` component no longer focuses itself on every keystroke.
- Fix #10988
- Same issue as above.
Other related changes:
- Fix word wrapping in tooltips when a word is wider than the entire tooltip
- Add word wrapping to "members" table in members tab
- Limit length of "email" column of members table in "user groups" tab
- Hide empty sections
- Add text to empty "user groups" table in "user groups" tab
- ~~Close "set organization name" modal when organization name is set~~
- Already fixed by another PR
# Important Notes
None
(cherry picked from commit 3bcc694af9
)
This commit is contained in:
parent
5f12bd52cb
commit
470f848b29
45
app/.vscode/launch.json
vendored
45
app/.vscode/launch.json
vendored
@ -6,49 +6,80 @@
|
||||
"request": "launch",
|
||||
"name": "Dashboard",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"]
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "dev"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Electron, Linux)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso", "watch:linux"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Electron, macOS)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso", "watch:macos"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Electron, Windows)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso", "watch:windows"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["dev:gui"]
|
||||
"runtimeArgs": ["dev:gui"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (Storybook)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"]
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "story:dev"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (Build)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["build:gui"]
|
||||
"runtimeArgs": ["build:gui"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (E2E UI)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"]
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "GUI (E2E UI)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"]
|
||||
"runtimeArgs": ["run", "--filter", "enso-gui2", "test:e2e", "--", "--ui"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Dashboard (All tests)",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"]
|
||||
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test"],
|
||||
"outputCapture": "std"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
|
@ -1,10 +1,16 @@
|
||||
/** @file Test the organization settings tab. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import { Plan } from 'enso-common/src/services/Backend'
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('organization settings', async ({ page }) => {
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({
|
||||
page,
|
||||
setupAPI: (theApi) => {
|
||||
theApi.setPlan(Plan.team)
|
||||
},
|
||||
})
|
||||
const localActions = actions.settings.organization
|
||||
|
||||
// Setup
|
||||
@ -76,7 +82,12 @@ test.test('organization settings', async ({ page }) => {
|
||||
})
|
||||
|
||||
test.test('upload organization profile picture', async ({ page }) => {
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({ page })
|
||||
const api = await actions.mockAllAndLoginAndExposeAPI({
|
||||
page,
|
||||
setupAPI: (theApi) => {
|
||||
theApi.setPlan(Plan.team)
|
||||
},
|
||||
})
|
||||
const localActions = actions.settings.organizationProfilePicture
|
||||
|
||||
await localActions.go(page)
|
||||
|
@ -1,7 +1,6 @@
|
||||
/** @file Alert component. */
|
||||
import { type ForwardedRef, type HTMLAttributes, type PropsWithChildren } from 'react'
|
||||
|
||||
import { mergeRefs } from '#/utilities/mergeRefs'
|
||||
import { forwardRef } from '#/utilities/react'
|
||||
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
|
||||
|
||||
@ -57,6 +56,7 @@ export interface AlertProps
|
||||
HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
/** Alert component. */
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const Alert = forwardRef(function Alert(
|
||||
props: AlertProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
@ -71,11 +71,7 @@ export const Alert = forwardRef(function Alert(
|
||||
return (
|
||||
<div
|
||||
className={ALERT_STYLES({ variant, size, className, rounded, fullWidth })}
|
||||
ref={mergeRefs(ref, (e) => {
|
||||
if (variant === 'error') {
|
||||
e?.focus()
|
||||
}
|
||||
})}
|
||||
ref={ref}
|
||||
{...containerProps}
|
||||
>
|
||||
{children}
|
||||
|
@ -1,49 +1,44 @@
|
||||
/** @file A button for closing a modal. */
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import Dismiss from '#/assets/dismiss.svg'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as button from '#/components/AriaComponents/Button'
|
||||
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
import DismissIcon from '#/assets/dismiss.svg'
|
||||
import { Button, type ButtonProps } from '#/components/AriaComponents/Button'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
import { twMerge } from '#/utilities/tailwindMerge'
|
||||
import { isOnMacOS } from 'enso-common/src/detect'
|
||||
|
||||
// ===================
|
||||
// === CloseButton ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link CloseButton}. */
|
||||
export type CloseButtonProps = Omit<
|
||||
button.ButtonProps,
|
||||
'children' | 'rounding' | 'size' | 'variant'
|
||||
>
|
||||
export type CloseButtonProps = Omit<ButtonProps, 'children' | 'rounding' | 'size' | 'variant'>
|
||||
|
||||
/** A styled button with a close icon that appears on hover. */
|
||||
export function CloseButton(props: CloseButtonProps) {
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
const {
|
||||
className,
|
||||
icon = Dismiss,
|
||||
icon = DismissIcon,
|
||||
tooltip = false,
|
||||
'aria-label': ariaLabel = getText('closeModalShortcut'),
|
||||
...buttonProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<button.Button
|
||||
<Button
|
||||
variant="icon"
|
||||
className={(values) =>
|
||||
tailwindMerge.twMerge(
|
||||
'bg-primary/30 hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
|
||||
twMerge(
|
||||
'hover:bg-red-500/80 focus-visible:bg-red-500/80 focus-visible:outline-offset-1',
|
||||
isOnMacOS() ? 'bg-primary/30' : (
|
||||
'text-primary/90 hover:text-primary focus-visible:text-primary'
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
// @ts-expect-error ts fails to infer the type of the className prop
|
||||
typeof className === 'function' ? className(values) : className,
|
||||
)
|
||||
}
|
||||
tooltip={tooltip}
|
||||
showIconOnHover
|
||||
showIconOnHover={isOnMacOS()}
|
||||
size="xsmall"
|
||||
rounded="full"
|
||||
extraClickZone="medium"
|
||||
|
@ -12,7 +12,7 @@ import * as text from '../Text'
|
||||
// =================
|
||||
|
||||
export const TOOLTIP_STYLES = twv.tv({
|
||||
base: 'group flex justify-center items-center text-center text-balance break-all z-50',
|
||||
base: 'group flex justify-center items-center text-center text-balance [overflow-wrap:anywhere] z-tooltip',
|
||||
variants: {
|
||||
variant: {
|
||||
custom: '',
|
||||
|
@ -9,9 +9,9 @@ import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import { IS_DEV_MODE } from 'enso-common/src/detect'
|
||||
|
||||
import cross from '#/assets/cross.svg'
|
||||
import CrossIcon from '#/assets/cross.svg'
|
||||
import DevtoolsLogo from '#/assets/enso_logo.svg'
|
||||
import trash from '#/assets/trash.svg'
|
||||
import TrashIcon from '#/assets/trash.svg'
|
||||
|
||||
import { SETUP_PATH } from '#/appUtils'
|
||||
|
||||
@ -45,7 +45,7 @@ import {
|
||||
} from '#/providers/FeatureFlagsProvider'
|
||||
import { useLocalStorage } from '#/providers/LocalStorageProvider'
|
||||
import * as backend from '#/services/Backend'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import LocalStorage, { type LocalStorageData } from '#/utilities/LocalStorage'
|
||||
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
|
||||
|
||||
/**
|
||||
@ -60,6 +60,10 @@ export function EnsoDevtools() {
|
||||
const enableVersionChecker = useEnableVersionChecker()
|
||||
const setEnableVersionChecker = useSetEnableVersionChecker()
|
||||
const { localStorage } = useLocalStorage()
|
||||
const [localStorageState, setLocalStorageState] = React.useState<Partial<LocalStorageData>>({})
|
||||
|
||||
// Re-render when localStorage changes.
|
||||
React.useEffect(() => localStorage.subscribeAll(setLocalStorageState), [localStorage])
|
||||
|
||||
const featureFlags = useFeatureFlags()
|
||||
const setFeatureFlags = useSetFeatureFlags()
|
||||
@ -157,46 +161,6 @@ export function EnsoDevtools() {
|
||||
)}
|
||||
</ariaComponents.Form>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<Text variant="subtitle">{getText('localStorage')}</Text>
|
||||
|
||||
<Button
|
||||
aria-label={getText('deleteAll')}
|
||||
size="small"
|
||||
variant="icon"
|
||||
icon={trash}
|
||||
onPress={() => {
|
||||
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
|
||||
localStorage.delete(key)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||
<div key={key} className="flex w-full items-center justify-between gap-1">
|
||||
<Text variant="body">
|
||||
{key
|
||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||
.replace(/^./, (m) => m.toUpperCase())}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
size="small"
|
||||
aria-label={getText('delete')}
|
||||
icon={cross}
|
||||
onPress={() => {
|
||||
localStorage.delete(key)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ariaComponents.Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<ariaComponents.Text variant="subtitle" className="mb-2">
|
||||
@ -292,6 +256,47 @@ export function EnsoDevtools() {
|
||||
)
|
||||
})}
|
||||
</ariaComponents.Form>
|
||||
|
||||
<Separator orientation="horizontal" className="my-3" />
|
||||
|
||||
<div className="mb-2 flex w-full items-center justify-between">
|
||||
<Text variant="subtitle">{getText('localStorage')}</Text>
|
||||
|
||||
<Button
|
||||
aria-label={getText('deleteAll')}
|
||||
size="small"
|
||||
variant="icon"
|
||||
icon={TrashIcon}
|
||||
onPress={() => {
|
||||
for (const [key] of unsafeEntries(LocalStorage.keyMetadata)) {
|
||||
localStorage.delete(key)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{unsafeEntries(LocalStorage.keyMetadata).map(([key]) => (
|
||||
<div key={key} className="flex w-full items-center justify-between gap-1">
|
||||
<Text variant="body">
|
||||
{key
|
||||
.replace(/[A-Z]/g, (m) => ' ' + m.toLowerCase())
|
||||
.replace(/^./, (m) => m.toUpperCase())}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
size="small"
|
||||
isDisabled={localStorageState[key] == null}
|
||||
aria-label={getText('delete')}
|
||||
icon={CrossIcon}
|
||||
onPress={() => {
|
||||
localStorage.delete(key)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popover>
|
||||
</ariaComponents.DialogTrigger>
|
||||
</Portal>
|
||||
|
@ -3,7 +3,7 @@ import { Button, type ButtonProps } from '#/components/AriaComponents'
|
||||
import { tv } from '#/utilities/tailwindVariants'
|
||||
|
||||
const SIDEBAR_TAB_BUTTON_STYLES = tv({
|
||||
base: 'font-medium',
|
||||
base: 'z-1 font-medium',
|
||||
variants: {
|
||||
isActive: { true: 'bg-white opacity-100' },
|
||||
},
|
||||
|
@ -1,6 +1,4 @@
|
||||
/** @file Settings tab for viewing and editing organization members. */
|
||||
import * as React from 'react'
|
||||
|
||||
import { useMutation, useSuspenseQueries } from '@tanstack/react-query'
|
||||
|
||||
import { backendMutationOptions } from '#/hooks/backendHooks'
|
||||
@ -92,10 +90,10 @@ export default function MembersSettingsSection() {
|
||||
<table className="table-fixed self-start rounded-rows">
|
||||
<thead>
|
||||
<tr className="h-row">
|
||||
<th className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
{getText('name')}
|
||||
</th>
|
||||
<th className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
<th className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
{getText('status')}
|
||||
</th>
|
||||
</tr>
|
||||
@ -103,9 +101,13 @@ export default function MembersSettingsSection() {
|
||||
<tbody className="select-text">
|
||||
{members.map((member) => (
|
||||
<tr key={member.email} className="group h-row rounded-rows-child">
|
||||
<td className="border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||
<span className="block text-sm">{member.email}</span>
|
||||
<span className="block text-xs text-primary/50">{member.name}</span>
|
||||
<td className="max-w-48 border-x-2 border-transparent bg-clip-padding px-4 py-1 first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||
<ariaComponents.Text truncate="1" className="block">
|
||||
{member.email}
|
||||
</ariaComponents.Text>
|
||||
<ariaComponents.Text truncate="1" className="block text-2xs text-primary/40">
|
||||
{member.name}
|
||||
</ariaComponents.Text>
|
||||
</td>
|
||||
<td className="border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:rounded-r-full last:border-r-0">
|
||||
<div className="flex flex-col">
|
||||
|
@ -132,11 +132,11 @@ export default function MembersTable(props: MembersTableProps) {
|
||||
<aria.TableHeader className="sticky top h-row">
|
||||
<aria.Column
|
||||
isRowHeader
|
||||
className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||
className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
|
||||
>
|
||||
{getText('name')}
|
||||
</aria.Column>
|
||||
<aria.Column className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
<aria.Column className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
|
||||
{getText('email')}
|
||||
</aria.Column>
|
||||
{/* Delete button. */}
|
||||
|
@ -1,13 +1,9 @@
|
||||
/** @file Rendering for a settings section. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as settingsData from '#/layouts/Settings/settingsData'
|
||||
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import { Text } from '#/components/AriaComponents'
|
||||
import FocusArea from '#/components/styled/FocusArea'
|
||||
import type { SettingsContext, SettingsSectionData } from '#/layouts/Settings/settingsData'
|
||||
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
|
||||
import { useText } from '#/providers/TextProvider'
|
||||
|
||||
// =======================
|
||||
// === SettingsSection ===
|
||||
@ -15,32 +11,33 @@ import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
/** Props for a {@link SettingsSection}. */
|
||||
export interface SettingsSectionProps {
|
||||
readonly context: settingsData.SettingsContext
|
||||
readonly data: settingsData.SettingsSectionData
|
||||
readonly context: SettingsContext
|
||||
readonly data: SettingsSectionData
|
||||
}
|
||||
|
||||
/** Rendering for a settings section. */
|
||||
export default function SettingsSection(props: SettingsSectionProps) {
|
||||
const { context, data } = props
|
||||
const { nameId, focusArea = true, heading = true, entries } = data
|
||||
const { getText } = textProvider.useText()
|
||||
const { getText } = useText()
|
||||
const isVisible = entries.some((entry) => entry.getVisible?.(context) ?? true)
|
||||
|
||||
return (
|
||||
<FocusArea active={focusArea} direction="vertical">
|
||||
{(innerProps) => (
|
||||
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
|
||||
{!heading ? null : (
|
||||
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold">
|
||||
{getText(nameId)}
|
||||
</aria.Heading>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry, i) => (
|
||||
<SettingsEntry key={i} context={context} data={entry} />
|
||||
))}
|
||||
return !isVisible ? null : (
|
||||
<FocusArea active={focusArea} direction="vertical">
|
||||
{(innerProps) => (
|
||||
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
|
||||
{!heading ? null : (
|
||||
<Text.Heading level={2} weight="bold">
|
||||
{getText(nameId)}
|
||||
</Text.Heading>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry, i) => (
|
||||
<SettingsEntry key={i} context={context} data={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
)}
|
||||
</FocusArea>
|
||||
)
|
||||
}
|
||||
|
@ -223,6 +223,14 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
|
||||
</div>
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
: userGroups.length === 0 ?
|
||||
<aria.Row className="h-row">
|
||||
<aria.Cell className="col-span-2 px-2.5 placeholder">
|
||||
{isAdmin ?
|
||||
getText('youHaveNoUserGroupsAdmin')
|
||||
: getText('youHaveNoUserGroupsNonAdmin')}
|
||||
</aria.Cell>
|
||||
</aria.Row>
|
||||
: (userGroup) => (
|
||||
<>
|
||||
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
|
||||
|
@ -1,6 +1,4 @@
|
||||
/** @file A row representing a user in a table of users. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Cross2 from '#/assets/cross2.svg'
|
||||
|
||||
import * as contextMenuHooks from '#/hooks/contextMenuHooks'
|
||||
@ -90,7 +88,9 @@ export default function UserRow(props: UserRowProps) {
|
||||
</div>
|
||||
</aria.Cell>
|
||||
<aria.Cell className="text whitespace-nowrap rounded-r-full border-x-2 border-transparent bg-clip-padding px-cell-x first:rounded-l-full last:border-r-0 group-selected:bg-selected-frame">
|
||||
{user.email}
|
||||
<ariaComponents.Text nowrap truncate="1" className="block">
|
||||
{user.email}
|
||||
</ariaComponents.Text>
|
||||
</aria.Cell>
|
||||
{doDeleteUserRaw == null ?
|
||||
null
|
||||
|
@ -152,6 +152,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
settingsTab: SettingsTabType.organization,
|
||||
icon: PeopleSettingsIcon,
|
||||
organizationOnly: true,
|
||||
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||
sections: [
|
||||
{
|
||||
nameId: 'organizationSettingsSection',
|
||||
@ -285,7 +286,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
settingsTab: SettingsTabType.billingAndPlans,
|
||||
icon: CreditCardIcon,
|
||||
organizationOnly: true,
|
||||
visible: (context) => context.organization?.subscription != null,
|
||||
visible: ({ organization }) => organization?.subscription != null,
|
||||
sections: [],
|
||||
onPress: (context) =>
|
||||
context.queryClient
|
||||
@ -312,6 +313,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
settingsTab: SettingsTabType.members,
|
||||
icon: PeopleIcon,
|
||||
organizationOnly: true,
|
||||
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||
feature: 'inviteUser',
|
||||
sections: [
|
||||
{
|
||||
@ -325,6 +327,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
settingsTab: SettingsTabType.userGroups,
|
||||
icon: PeopleSettingsIcon,
|
||||
organizationOnly: true,
|
||||
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||
feature: 'userGroups',
|
||||
sections: [
|
||||
{
|
||||
@ -391,6 +394,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
|
||||
settingsTab: SettingsTabType.activityLog,
|
||||
icon: LogIcon,
|
||||
organizationOnly: true,
|
||||
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
|
||||
sections: [
|
||||
{
|
||||
nameId: 'activityLogSettingsSection',
|
||||
@ -470,6 +474,7 @@ export interface SettingsInputEntryData {
|
||||
readonly setValue: (context: SettingsContext, value: string) => Promise<void>
|
||||
readonly validate?: (value: string, context: SettingsContext) => string | true
|
||||
readonly getEditable: (context: SettingsContext) => boolean
|
||||
readonly getVisible?: (context: SettingsContext) => boolean
|
||||
}
|
||||
|
||||
// ===============================
|
||||
|
@ -56,7 +56,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
|
||||
: <div key={name} className="flex flex-col items-start">
|
||||
<Header
|
||||
id={`${name}_header`}
|
||||
className="mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
|
||||
className="z-1 mb-sidebar-section-heading-b h-text px-sidebar-section-heading-x py-sidebar-section-heading-y text-[13.5px] font-bold leading-cozy"
|
||||
>
|
||||
{name}
|
||||
</Header>
|
||||
|
@ -86,6 +86,7 @@ export default class LocalStorage {
|
||||
set<K extends LocalStorageKey>(key: K, value: LocalStorageData[K]) {
|
||||
this.values[key] = value
|
||||
this.eventTarget.dispatchEvent(new Event(key))
|
||||
this.eventTarget.dispatchEvent(new Event('_change'))
|
||||
this.save()
|
||||
}
|
||||
|
||||
@ -96,6 +97,7 @@ export default class LocalStorage {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete this.values[key]
|
||||
this.eventTarget.dispatchEvent(new Event(key))
|
||||
this.eventTarget.dispatchEvent(new Event('_change'))
|
||||
this.save()
|
||||
return oldValue
|
||||
}
|
||||
@ -114,13 +116,23 @@ export default class LocalStorage {
|
||||
key: K,
|
||||
callback: (value: LocalStorageData[K] | undefined) => void,
|
||||
) {
|
||||
const wrapped = () => {
|
||||
const value = this.values[key]
|
||||
callback(value)
|
||||
const onChange = () => {
|
||||
callback(this.values[key])
|
||||
}
|
||||
this.eventTarget.addEventListener(key, wrapped)
|
||||
this.eventTarget.addEventListener(key, onChange)
|
||||
return () => {
|
||||
this.eventTarget.removeEventListener(key, wrapped)
|
||||
this.eventTarget.removeEventListener(key, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
/** Add an event listener to all keys. */
|
||||
subscribeAll(callback: (value: Partial<LocalStorageData>) => void) {
|
||||
const onChange = () => {
|
||||
callback(this.values)
|
||||
}
|
||||
this.eventTarget.addEventListener('_change', onChange)
|
||||
return () => {
|
||||
this.eventTarget.removeEventListener('_change', onChange)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,6 +65,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
naming: ['"Enso Naming"', '"Enso"', '"M PLUS 1"'],
|
||||
},
|
||||
fontSize: {
|
||||
'2xs': '10.5px',
|
||||
xs: '11.5px',
|
||||
sm: '13px',
|
||||
xl: '19px',
|
||||
@ -344,7 +345,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
|
||||
},
|
||||
zIndex: {
|
||||
1: '1',
|
||||
3: '3',
|
||||
tooltip: '2',
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
|
@ -505,6 +505,21 @@ export interface CreateCustomerPortalSessionResponse {
|
||||
readonly url: string | null
|
||||
}
|
||||
|
||||
/** Whether the user is on a plan associated with an organization. */
|
||||
export function isUserOnPlanWithOrganization(user: User) {
|
||||
switch (user.plan) {
|
||||
case undefined:
|
||||
case Plan.free:
|
||||
case Plan.solo: {
|
||||
return false
|
||||
}
|
||||
case Plan.team:
|
||||
case Plan.enterprise: {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an {@link AssetPermission} is a {@link UserPermission}. */
|
||||
export function isUserPermission(permission: AssetPermission): permission is UserPermission {
|
||||
return 'user' in permission
|
||||
|
@ -64,7 +64,7 @@
|
||||
"missingVerificationCodeError": "Missing verification code",
|
||||
"passwordMismatchError": "Passwords do not match.",
|
||||
"passwordLengthError": "Your password must be between 6 and 256 characters long.",
|
||||
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols.",
|
||||
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols ( ^$*.[]{}()?\"!@#%&/,><':;|_~`=+-).",
|
||||
"passwordValidationError": "Your password does not meet the security requirements.",
|
||||
|
||||
"confirmSignUpError": "Incorrect email or confirmation code.",
|
||||
@ -462,6 +462,8 @@
|
||||
"organizationInviteSuffix": "' is inviting you. Would you like to accept? All your assets will be moved with you to your personal space.",
|
||||
"organizationInviteErrorSuffix": "' is inviting you.",
|
||||
"organizationInviteErrorMessage": "Something went wrong. Please contact the administrators at",
|
||||
"youHaveNoUserGroupsAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||
"youHaveNoUserGroupsNonAdmin": "This organization has no user groups. You can create one using the button above.",
|
||||
|
||||
"enableMultitabs": "Enable Multi-Tabs",
|
||||
"enableMultitabsDescription": "Open multiple projects at the same time.",
|
||||
|
Loading…
Reference in New Issue
Block a user