Organization and Pro tier link sharing features exposure (#6986)
* Improve url sharing for orgs and pros * Remove sharing via menu item * fmt * Not sure what's different but it is * fmt & lint * whoops * Update snapshots * Typos from codespell * Fix alignment * Update snapshots * Prune --------- Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
This commit is contained in:
@ -15,15 +15,9 @@ import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
||||
import usePlatform from '@src/hooks/usePlatform'
|
||||
import { APP_NAME } from '@src/lib/constants'
|
||||
import { isDesktop } from '@src/lib/isDesktop'
|
||||
import { copyFileShareLink } from '@src/lib/links'
|
||||
import { PATHS } from '@src/lib/paths'
|
||||
import {
|
||||
codeManager,
|
||||
engineCommandManager,
|
||||
kclManager,
|
||||
} from '@src/lib/singletons'
|
||||
import { engineCommandManager, kclManager } from '@src/lib/singletons'
|
||||
import { type IndexLoaderData } from '@src/lib/types'
|
||||
import { useToken } from '@src/lib/singletons'
|
||||
import { commandBarActor } from '@src/lib/singletons'
|
||||
|
||||
const ProjectSidebarMenu = ({
|
||||
@ -108,7 +102,6 @@ function ProjectMenuPopover({
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const filePath = useAbsoluteFilePath()
|
||||
const token = useToken()
|
||||
const machineManager = useContext(MachineManagerContext)
|
||||
const commands = useSelector(commandBarActor, commandsSelector)
|
||||
|
||||
@ -116,7 +109,6 @@ function ProjectMenuPopover({
|
||||
const insertCommandInfo = { name: 'Insert', groupId: 'code' }
|
||||
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
|
||||
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
|
||||
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
|
||||
const findCommand = (obj: { name: string; groupId: string }) =>
|
||||
Boolean(
|
||||
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
|
||||
@ -217,19 +209,6 @@ function ProjectMenuPopover({
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'share-link',
|
||||
Element: 'button',
|
||||
children: 'Share part via Zoo link',
|
||||
disabled: !findCommand(shareCommandInfo),
|
||||
onClick: async () => {
|
||||
await copyFileShareLink({
|
||||
token: token ?? '',
|
||||
code: codeManager.code,
|
||||
name: project?.name || '',
|
||||
})
|
||||
},
|
||||
},
|
||||
'break',
|
||||
{
|
||||
id: 'go-home',
|
||||
|
@ -1,53 +1,168 @@
|
||||
import { err } from '@src/lib/trap'
|
||||
import { CustomIcon } from '@src/components/CustomIcon'
|
||||
import Tooltip from '@src/components/Tooltip'
|
||||
import usePlatform from '@src/hooks/usePlatform'
|
||||
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
|
||||
import { commandBarActor } from '@src/lib/singletons'
|
||||
import { billingActor, commandBarActor } from '@src/lib/singletons'
|
||||
import { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { Popover } from '@headlessui/react'
|
||||
import { useSelector } from '@xstate/react'
|
||||
import { useKclContext } from '@src/lang/KclProvider'
|
||||
import { Tier } from '@src/machines/billingMachine'
|
||||
import type { SubscriptionsOrError } from '@src/machines/billingMachine'
|
||||
|
||||
const shareHotkey = 'mod+alt+s'
|
||||
const onShareClick = () =>
|
||||
commandBarActor.send({
|
||||
type: 'Find and select command',
|
||||
data: { name: 'share-file-link', groupId: 'code' },
|
||||
})
|
||||
|
||||
const canPasswordProtectShareLinks = (
|
||||
subOrErr: undefined | SubscriptionsOrError
|
||||
): boolean => {
|
||||
if (subOrErr === undefined || typeof subOrErr === 'number' || err(subOrErr))
|
||||
return false
|
||||
return subOrErr.modeling_app.share_links[0] === 'password_protected'
|
||||
}
|
||||
|
||||
/** Share Zoo link button shown in the upper-right of the modeling view */
|
||||
export const ShareButton = () => {
|
||||
const platform = usePlatform()
|
||||
useHotkeys(shareHotkey, onShareClick, {
|
||||
|
||||
const [showOptions, setShowOptions] = useState(false)
|
||||
const [isRestrictedToOrg, setIsRestrictedToOrg] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const billingContext = useSelector(billingActor, ({ context }) => context)
|
||||
|
||||
const allowOrgRestrict = billingContext.tier === Tier.Organization
|
||||
const allowPassword = canPasswordProtectShareLinks(
|
||||
billingContext.subscriptionsOrError
|
||||
)
|
||||
const hasOptions = allowOrgRestrict || allowPassword
|
||||
|
||||
// Prevents Organization and Pro tier users from one-click sharing,
|
||||
// and give them a chance to set a password and restrict to org.
|
||||
const onShareClickFreeOrUnknownRestricted = () => {
|
||||
if (hasOptions) {
|
||||
setShowOptions(true)
|
||||
return
|
||||
}
|
||||
|
||||
commandBarActor.send({
|
||||
type: 'Find and select command',
|
||||
data: {
|
||||
name: 'share-file-link',
|
||||
groupId: 'code',
|
||||
isRestrictedToOrg: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const onShareClickProOrOrganization = () => {
|
||||
setShowOptions(false)
|
||||
|
||||
commandBarActor.send({
|
||||
type: 'Find and select command',
|
||||
data: {
|
||||
name: 'share-file-link',
|
||||
groupId: 'code',
|
||||
isRestrictedToOrg,
|
||||
password,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useHotkeys(shareHotkey, onShareClickFreeOrUnknownRestricted, {
|
||||
scopes: ['modeling'],
|
||||
})
|
||||
|
||||
const kclContext = useKclContext()
|
||||
const disabled = kclContext.ast.body.some((n) => n.type === 'ImportStatement')
|
||||
|
||||
// It doesn't make sense for the user to be able to click on this
|
||||
// until we get what their subscription allows for.
|
||||
const disabled =
|
||||
kclContext.ast.body.some((n) => n.type === 'ImportStatement') ||
|
||||
billingContext.tier === undefined
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShareClick}
|
||||
disabled={disabled}
|
||||
className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary"
|
||||
data-testid="share-button"
|
||||
>
|
||||
<CustomIcon name="link" className="w-5 h-5" />
|
||||
<span className="flex-1">Share</span>
|
||||
<Tooltip
|
||||
position="bottom-right"
|
||||
contentClassName="max-w-none flex items-center gap-4"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{disabled
|
||||
? `Share links are not currently supported for multi-file assemblies`
|
||||
: `Share part via Zoo link`}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<kbd className="hotkey text-xs capitalize">
|
||||
{hotkeyDisplay(shareHotkey, platform)}
|
||||
</kbd>
|
||||
)}
|
||||
</Tooltip>
|
||||
</button>
|
||||
<Popover className="relative flex">
|
||||
<Popover.Button className="relative group border-0 w-fit min-w-max p-0 rounded-l-full focus-visible:outline-appForeground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShareClickFreeOrUnknownRestricted}
|
||||
disabled={disabled}
|
||||
className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary"
|
||||
data-testid="share-button"
|
||||
>
|
||||
<CustomIcon name="link" className="w-5 h-5" />
|
||||
<span className="flex-1">Share</span>
|
||||
<Tooltip
|
||||
position="bottom-right"
|
||||
contentClassName="max-w-none flex items-center gap-4"
|
||||
>
|
||||
<span className="flex-1">
|
||||
{disabled
|
||||
? `Share links are not currently supported for multi-file assemblies`
|
||||
: `Share part via Zoo link`}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<kbd className="hotkey text-xs capitalize">
|
||||
{hotkeyDisplay(shareHotkey, platform)}
|
||||
</kbd>
|
||||
)}
|
||||
</Tooltip>
|
||||
</button>
|
||||
</Popover.Button>
|
||||
{showOptions && (
|
||||
<Popover.Panel
|
||||
focus={true}
|
||||
className={`z-10 absolute top-full right-0 mt-1 pb-1 w-48 bg-chalkboard-10 dark:bg-chalkboard-90
|
||||
border border-solid border-chalkboard-20 dark:border-chalkboard-90 rounded
|
||||
shadow-lg`}
|
||||
>
|
||||
<div className="flex flex-col px-2">
|
||||
<div className="flex flex-row gap-1 items-center">
|
||||
<CustomIcon name="lockClosed" className="w-6 h-6" />
|
||||
<input
|
||||
disabled={!allowPassword}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
className={`${allowPassword ? 'cursor-pointer' : 'cursor-not-allowed'} text-xs w-full py-1 bg-transparent text-chalkboard-100 placeholder:text-chalkboard-70 dark:text-chalkboard-10 dark:placeholder:text-chalkboard-50 focus:outline-none focus:ring-0`}
|
||||
type="text"
|
||||
placeholder="Set a password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="org-only"
|
||||
className="pl-1 inline-flex items-center"
|
||||
>
|
||||
<input
|
||||
disabled={!allowOrgRestrict}
|
||||
checked={isRestrictedToOrg}
|
||||
onChange={(_) => setIsRestrictedToOrg(!isRestrictedToOrg)}
|
||||
type="checkbox"
|
||||
name="org-only"
|
||||
className="form-checkbox"
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ml-2 ${allowOrgRestrict ? 'cursor-pointer' : 'cursor-not-allowed text-chalkboard-50'}`}
|
||||
>
|
||||
Org. only access
|
||||
</span>
|
||||
</label>
|
||||
{!allowOrgRestrict && (
|
||||
<Tooltip>Upgrade to Organization to use this feature.</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<button disabled={disabled} onClick={onShareClickProOrOrganization}>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
)}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user