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>
@ -197,18 +197,6 @@ test.describe(
|
|||||||
await clickElectronNativeMenuById(tronApp, 'File.Export current part')
|
await clickElectronNativeMenuById(tronApp, 'File.Export current part')
|
||||||
await cmdBar.expectCommandName('Export')
|
await cmdBar.expectCommandName('Export')
|
||||||
})
|
})
|
||||||
await test.step('Modeling.File.Share part via Zoo link', async () => {
|
|
||||||
await page.waitForTimeout(250)
|
|
||||||
await clickElectronNativeMenuById(
|
|
||||||
tronApp,
|
|
||||||
'File.Share part via Zoo link'
|
|
||||||
)
|
|
||||||
const textToCheck =
|
|
||||||
'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!'
|
|
||||||
// Check if text appears anywhere in the page
|
|
||||||
const isTextVisible = page.getByText(textToCheck)
|
|
||||||
await expect(isTextVisible).toBeVisible({ timeout: 10000 })
|
|
||||||
})
|
|
||||||
await test.step('Modeling.File.Preferences.Project settings', async () => {
|
await test.step('Modeling.File.Preferences.Project settings', async () => {
|
||||||
await page.waitForTimeout(250)
|
await page.waitForTimeout(250)
|
||||||
await clickElectronNativeMenuById(
|
await clickElectronNativeMenuById(
|
||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 116 KiB |
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
@ -2770,7 +2770,7 @@ mod clone_w_fillets {
|
|||||||
/// Test that KCL is executed correctly.
|
/// Test that KCL is executed correctly.
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[ignore] // turn on when https://github.com/KittyCAD/engine/pull/3380 is merged
|
#[ignore] // turn on when https://github.com/KittyCAD/engine/pull/3380 is merged
|
||||||
// Theres also a test in clone.rs you need to turn too
|
// There's also a test in clone.rs you need to turn too
|
||||||
async fn kcl_test_execute() {
|
async fn kcl_test_execute() {
|
||||||
super::execute(TEST_NAME, true).await
|
super::execute(TEST_NAME, true).await
|
||||||
}
|
}
|
||||||
|
@ -15,15 +15,9 @@ import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
|
|||||||
import usePlatform from '@src/hooks/usePlatform'
|
import usePlatform from '@src/hooks/usePlatform'
|
||||||
import { APP_NAME } from '@src/lib/constants'
|
import { APP_NAME } from '@src/lib/constants'
|
||||||
import { isDesktop } from '@src/lib/isDesktop'
|
import { isDesktop } from '@src/lib/isDesktop'
|
||||||
import { copyFileShareLink } from '@src/lib/links'
|
|
||||||
import { PATHS } from '@src/lib/paths'
|
import { PATHS } from '@src/lib/paths'
|
||||||
import {
|
import { engineCommandManager, kclManager } from '@src/lib/singletons'
|
||||||
codeManager,
|
|
||||||
engineCommandManager,
|
|
||||||
kclManager,
|
|
||||||
} from '@src/lib/singletons'
|
|
||||||
import { type IndexLoaderData } from '@src/lib/types'
|
import { type IndexLoaderData } from '@src/lib/types'
|
||||||
import { useToken } from '@src/lib/singletons'
|
|
||||||
import { commandBarActor } from '@src/lib/singletons'
|
import { commandBarActor } from '@src/lib/singletons'
|
||||||
|
|
||||||
const ProjectSidebarMenu = ({
|
const ProjectSidebarMenu = ({
|
||||||
@ -108,7 +102,6 @@ function ProjectMenuPopover({
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const filePath = useAbsoluteFilePath()
|
const filePath = useAbsoluteFilePath()
|
||||||
const token = useToken()
|
|
||||||
const machineManager = useContext(MachineManagerContext)
|
const machineManager = useContext(MachineManagerContext)
|
||||||
const commands = useSelector(commandBarActor, commandsSelector)
|
const commands = useSelector(commandBarActor, commandsSelector)
|
||||||
|
|
||||||
@ -116,7 +109,6 @@ function ProjectMenuPopover({
|
|||||||
const insertCommandInfo = { name: 'Insert', groupId: 'code' }
|
const insertCommandInfo = { name: 'Insert', groupId: 'code' }
|
||||||
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
|
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
|
||||||
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
|
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
|
||||||
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
|
|
||||||
const findCommand = (obj: { name: string; groupId: string }) =>
|
const findCommand = (obj: { name: string; groupId: string }) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
|
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',
|
'break',
|
||||||
{
|
{
|
||||||
id: 'go-home',
|
id: 'go-home',
|
||||||
|
@ -1,32 +1,93 @@
|
|||||||
|
import { err } from '@src/lib/trap'
|
||||||
import { CustomIcon } from '@src/components/CustomIcon'
|
import { CustomIcon } from '@src/components/CustomIcon'
|
||||||
import Tooltip from '@src/components/Tooltip'
|
import Tooltip from '@src/components/Tooltip'
|
||||||
import usePlatform from '@src/hooks/usePlatform'
|
import usePlatform from '@src/hooks/usePlatform'
|
||||||
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
|
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 { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import { Popover } from '@headlessui/react'
|
||||||
|
import { useSelector } from '@xstate/react'
|
||||||
import { useKclContext } from '@src/lang/KclProvider'
|
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 shareHotkey = 'mod+alt+s'
|
||||||
const onShareClick = () =>
|
|
||||||
commandBarActor.send({
|
const canPasswordProtectShareLinks = (
|
||||||
type: 'Find and select command',
|
subOrErr: undefined | SubscriptionsOrError
|
||||||
data: { name: 'share-file-link', groupId: 'code' },
|
): 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 */
|
/** Share Zoo link button shown in the upper-right of the modeling view */
|
||||||
export const ShareButton = () => {
|
export const ShareButton = () => {
|
||||||
const platform = usePlatform()
|
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'],
|
scopes: ['modeling'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const kclContext = useKclContext()
|
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 (
|
return (
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onShareClick}
|
onClick={onShareClickFreeOrUnknownRestricted}
|
||||||
disabled={disabled}
|
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"
|
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"
|
data-testid="share-button"
|
||||||
@ -49,5 +110,59 @@ export const ShareButton = () => {
|
|||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</button>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -49,6 +49,7 @@ export function useCreateFileLinkQuery(
|
|||||||
decodeURIComponent(searchParams.get('code') ?? '')
|
decodeURIComponent(searchParams.get('code') ?? '')
|
||||||
),
|
),
|
||||||
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
|
name: searchParams.get('name') ?? DEFAULT_FILE_NAME,
|
||||||
|
isRestrictedToOrg: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const argDefaultValues: CreateFileSchemaMethodOptional = {
|
const argDefaultValues: CreateFileSchemaMethodOptional = {
|
||||||
|
@ -32,6 +32,8 @@ interface KclCommandConfig {
|
|||||||
settings: {
|
settings: {
|
||||||
defaultUnit: UnitLength_type
|
defaultUnit: UnitLength_type
|
||||||
}
|
}
|
||||||
|
isRestrictedToOrg?: boolean
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
||||||
@ -175,11 +177,13 @@ export function kclCommands(commandProps: KclCommandConfig): Command[] {
|
|||||||
groupId: 'code',
|
groupId: 'code',
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
icon: 'link',
|
icon: 'link',
|
||||||
onSubmit: () => {
|
onSubmit: (input) => {
|
||||||
copyFileShareLink({
|
copyFileShareLink({
|
||||||
token: commandProps.authToken,
|
token: commandProps.authToken,
|
||||||
code: codeManager.code,
|
code: codeManager.code,
|
||||||
name: commandProps.projectData.project?.name || '',
|
name: commandProps.projectData.project?.name || '',
|
||||||
|
isRestrictedToOrg: input?.event.data.isRestrictedToOrg ?? false,
|
||||||
|
password: input?.event.data.password,
|
||||||
}).catch(reportRejection)
|
}).catch(reportRejection)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -11,7 +11,7 @@ describe(`link creation tests`, () => {
|
|||||||
const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D`
|
const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D`
|
||||||
const expectedLink = `${VITE_KC_SITE_APP_URL}/?create-file=true&name=test&code=${expectedEncodedCode}&ask-open-desktop=true`
|
const expectedLink = `${VITE_KC_SITE_APP_URL}/?create-file=true&name=test&code=${expectedEncodedCode}&ask-open-desktop=true`
|
||||||
|
|
||||||
const result = createCreateFileUrl({ code, name })
|
const result = createCreateFileUrl({ code, name, isRestrictedToOrg: false })
|
||||||
expect(result.toString()).toBe(expectedLink)
|
expect(result.toString()).toBe(expectedLink)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -11,6 +11,8 @@ import { err } from '@src/lib/trap'
|
|||||||
export interface FileLinkParams {
|
export interface FileLinkParams {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
isRestrictedToOrg: boolean
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function copyFileShareLink(
|
export async function copyFileShareLink(
|
||||||
@ -24,7 +26,12 @@ export async function copyFileShareLink(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const shareUrl = createCreateFileUrl(args)
|
const shareUrl = createCreateFileUrl(args)
|
||||||
const shortlink = await createShortlink(token, shareUrl.toString())
|
const shortlink = await createShortlink(
|
||||||
|
token,
|
||||||
|
shareUrl.toString(),
|
||||||
|
args.isRestrictedToOrg,
|
||||||
|
args.password
|
||||||
|
)
|
||||||
|
|
||||||
if (err(shortlink)) {
|
if (err(shortlink)) {
|
||||||
toast.error(shortlink.message, {
|
toast.error(shortlink.message, {
|
||||||
@ -70,23 +77,32 @@ export function createCreateFileUrl({ code, name }: FileLinkParams) {
|
|||||||
*/
|
*/
|
||||||
export async function createShortlink(
|
export async function createShortlink(
|
||||||
token: string,
|
token: string,
|
||||||
url: string
|
url: string,
|
||||||
|
isRestrictedToOrg: boolean,
|
||||||
|
password?: string
|
||||||
): Promise<Error | { key: string; url: string }> {
|
): Promise<Error | { key: string; url: string }> {
|
||||||
/**
|
/**
|
||||||
* We don't use our `withBaseURL` function here because
|
* We don't use our `withBaseURL` function here because
|
||||||
* there is no URL shortener service in the dev API.
|
* there is no URL shortener service in the dev API.
|
||||||
*/
|
*/
|
||||||
|
const body: {
|
||||||
|
url: string
|
||||||
|
restrict_to_org: boolean
|
||||||
|
password?: string
|
||||||
|
} = {
|
||||||
|
url,
|
||||||
|
restrict_to_org: isRestrictedToOrg,
|
||||||
|
}
|
||||||
|
if (password) {
|
||||||
|
body.password = password
|
||||||
|
}
|
||||||
const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, {
|
const response = await fetch(`${VITE_KC_API_BASE_URL}/user/shortlinks`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-type': 'application/json',
|
'Content-type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
url,
|
|
||||||
// In future we can support org-scoped and password-protected shortlinks here
|
|
||||||
// https://zoo.dev/docs/api/shortlinks/create-a-shortlink-for-a-user?lang=typescript
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json()
|
const error = await response.json()
|
||||||
|
@ -14,11 +14,34 @@ export enum BillingTransition {
|
|||||||
Wait = 'wait',
|
Wait = 'wait',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// It's nice to be explicit if we are an Organization, Pro, Free.
|
||||||
|
// @kittycad/lib offers some types around this, but they aren't as...
|
||||||
|
// homogeneous: Models['ZooProductSubscriptions_type'], and
|
||||||
|
// Models['Org_type'].
|
||||||
|
export enum Tier {
|
||||||
|
Free = 'free',
|
||||||
|
Pro = 'pro',
|
||||||
|
Organization = 'organization',
|
||||||
|
Unknown = 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrgOrError = Models['Org_type'] | number | Error
|
||||||
|
export type SubscriptionsOrError =
|
||||||
|
| Models['ZooProductSubscriptions_type']
|
||||||
|
| number
|
||||||
|
| Error
|
||||||
|
export type TierBasedOn = {
|
||||||
|
orgOrError: OrgOrError
|
||||||
|
subscriptionsOrError: SubscriptionsOrError
|
||||||
|
}
|
||||||
|
|
||||||
export interface BillingContext {
|
export interface BillingContext {
|
||||||
credits: undefined | number
|
credits: undefined | number
|
||||||
allowance: undefined | number
|
allowance: undefined | number
|
||||||
error: undefined | Error
|
error: undefined | Error
|
||||||
urlUserService: string
|
urlUserService: string
|
||||||
|
tier: undefined | Tier
|
||||||
|
subscriptionsOrError: undefined | SubscriptionsOrError
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BillingUpdateEvent {
|
export interface BillingUpdateEvent {
|
||||||
@ -30,9 +53,30 @@ export const BILLING_CONTEXT_DEFAULTS: BillingContext = Object.freeze({
|
|||||||
credits: undefined,
|
credits: undefined,
|
||||||
allowance: undefined,
|
allowance: undefined,
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
tier: undefined,
|
||||||
|
subscriptionsOrError: undefined,
|
||||||
urlUserService: '',
|
urlUserService: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toTierFrom = (args: TierBasedOn): Tier => {
|
||||||
|
if (typeof args.orgOrError !== 'number' && !err(args.orgOrError)) {
|
||||||
|
return Tier.Organization
|
||||||
|
} else if (
|
||||||
|
typeof args.subscriptionsOrError !== 'number' &&
|
||||||
|
!err(args.subscriptionsOrError)
|
||||||
|
) {
|
||||||
|
const subscriptions: Models['ZooProductSubscriptions_type'] =
|
||||||
|
args.subscriptionsOrError
|
||||||
|
if (subscriptions.modeling_app.name === 'pro') {
|
||||||
|
return Tier.Pro
|
||||||
|
} else {
|
||||||
|
return Tier.Free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Tier.Unknown
|
||||||
|
}
|
||||||
|
|
||||||
export const billingMachine = setup({
|
export const billingMachine = setup({
|
||||||
types: {
|
types: {
|
||||||
context: {} as BillingContext,
|
context: {} as BillingContext,
|
||||||
@ -72,33 +116,45 @@ export const billingMachine = setup({
|
|||||||
input.event.apiToken
|
input.event.apiToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tier = toTierFrom({
|
||||||
|
orgOrError,
|
||||||
|
subscriptionsOrError,
|
||||||
|
})
|
||||||
|
|
||||||
let credits =
|
let credits =
|
||||||
Number(billing.monthly_api_credits_remaining) +
|
Number(billing.monthly_api_credits_remaining) +
|
||||||
Number(billing.stable_api_credits_remaining)
|
Number(billing.stable_api_credits_remaining)
|
||||||
let allowance = undefined
|
let allowance = undefined
|
||||||
|
|
||||||
// If user is part of an org, the endpoint will return data.
|
switch (tier) {
|
||||||
if (typeof orgOrError !== 'number' && !err(orgOrError)) {
|
case Tier.Organization:
|
||||||
|
case Tier.Pro:
|
||||||
credits = Infinity
|
credits = Infinity
|
||||||
// Otherwise they are on a Pro or Free subscription
|
break
|
||||||
} else if (
|
case Tier.Free:
|
||||||
|
// TS too dumb Tier.Free has the same logic
|
||||||
|
if (
|
||||||
typeof subscriptionsOrError !== 'number' &&
|
typeof subscriptionsOrError !== 'number' &&
|
||||||
!err(subscriptionsOrError)
|
!err(subscriptionsOrError)
|
||||||
) {
|
) {
|
||||||
const subscriptions: Models['ZooProductSubscriptions_type'] =
|
|
||||||
subscriptionsOrError
|
|
||||||
if (subscriptions.modeling_app.name === 'pro') {
|
|
||||||
credits = Infinity
|
|
||||||
} else {
|
|
||||||
allowance = Number(
|
allowance = Number(
|
||||||
subscriptions.modeling_app.monthly_pay_as_you_go_api_credits
|
subscriptionsOrError.modeling_app
|
||||||
|
.monthly_pay_as_you_go_api_credits
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
break
|
||||||
|
case Tier.Unknown:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
const _exh: never = tier
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nothing matches, we show a credit total.
|
// If nothing matches, we show a credit total.
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error: undefined,
|
error: undefined,
|
||||||
|
tier,
|
||||||
|
subscriptionsOrError,
|
||||||
credits,
|
credits,
|
||||||
allowance,
|
allowance,
|
||||||
}
|
}
|
||||||
|
@ -66,6 +66,10 @@ export type CommandBarMachineEvent =
|
|||||||
name: string
|
name: string
|
||||||
groupId: string
|
groupId: string
|
||||||
argDefaultValues?: { [x: string]: unknown }
|
argDefaultValues?: { [x: string]: unknown }
|
||||||
|
|
||||||
|
// I'm sorry but the way we did share URL called for this.
|
||||||
|
isRestrictedToOrg?: boolean
|
||||||
|
password?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -115,7 +119,7 @@ export const commandBarMachine = setup({
|
|||||||
}
|
}
|
||||||
selectedCommand?.onSubmit(resolvedArgs)
|
selectedCommand?.onSubmit(resolvedArgs)
|
||||||
} else {
|
} else {
|
||||||
selectedCommand?.onSubmit()
|
selectedCommand?.onSubmit({ context, event })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'Clear selected command': assign({
|
'Clear selected command': assign({
|
||||||
|
@ -26,7 +26,6 @@ export type MenuLabels =
|
|||||||
| 'File.Create new folder'
|
| 'File.Create new folder'
|
||||||
| 'File.Add file to project'
|
| 'File.Add file to project'
|
||||||
| 'File.Export current part'
|
| 'File.Export current part'
|
||||||
| 'File.Share part via Zoo link'
|
|
||||||
| 'File.Preferences.Project settings'
|
| 'File.Preferences.Project settings'
|
||||||
| 'Design.Start sketch'
|
| 'Design.Start sketch'
|
||||||
| 'Design.Create an offset plane'
|
| 'Design.Create an offset plane'
|
||||||
|
@ -184,15 +184,6 @@ export const modelingFileRole = (
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Share part via Zoo link',
|
|
||||||
id: 'File.Share part via Zoo link',
|
|
||||||
click: () => {
|
|
||||||
typeSafeWebContentsSend(mainWindow, 'menu-action-clicked', {
|
|
||||||
menuLabel: 'File.Share part via Zoo link',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Preferences',
|
label: 'Preferences',
|
||||||
|
@ -1,13 +1,8 @@
|
|||||||
import { AxisNames } from '@src/lib/constants'
|
import { AxisNames } from '@src/lib/constants'
|
||||||
import { copyFileShareLink } from '@src/lib/links'
|
|
||||||
import { PATHS } from '@src/lib/paths'
|
import { PATHS } from '@src/lib/paths'
|
||||||
import type { Project } from '@src/lib/project'
|
import type { Project } from '@src/lib/project'
|
||||||
import type { SettingsType } from '@src/lib/settings/initialSettings'
|
import type { SettingsType } from '@src/lib/settings/initialSettings'
|
||||||
import {
|
import { engineCommandManager, sceneInfra } from '@src/lib/singletons'
|
||||||
codeManager,
|
|
||||||
engineCommandManager,
|
|
||||||
sceneInfra,
|
|
||||||
} from '@src/lib/singletons'
|
|
||||||
import { reportRejection } from '@src/lib/trap'
|
import { reportRejection } from '@src/lib/trap'
|
||||||
import { uuidv4 } from '@src/lib/utils'
|
import { uuidv4 } from '@src/lib/utils'
|
||||||
import { authActor, settingsActor } from '@src/lib/singletons'
|
import { authActor, settingsActor } from '@src/lib/singletons'
|
||||||
@ -84,12 +79,6 @@ export function modelingMenuCallbackMostActions(
|
|||||||
})
|
})
|
||||||
} else if (data.menuLabel === 'File.Preferences.Theme color') {
|
} else if (data.menuLabel === 'File.Preferences.Theme color') {
|
||||||
navigate(filePath + PATHS.SETTINGS_USER + '#themeColor')
|
navigate(filePath + PATHS.SETTINGS_USER + '#themeColor')
|
||||||
} else if (data.menuLabel === 'File.Share part via Zoo link') {
|
|
||||||
copyFileShareLink({
|
|
||||||
token: token ?? '',
|
|
||||||
code: codeManager.code,
|
|
||||||
name: project?.name || '',
|
|
||||||
}).catch(reportRejection)
|
|
||||||
} else if (data.menuLabel === 'File.Preferences.User default units') {
|
} else if (data.menuLabel === 'File.Preferences.User default units') {
|
||||||
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
|
navigate(filePath + PATHS.SETTINGS_USER + '#defaultUnit')
|
||||||
} else if (data.menuLabel === 'File.Add file to project') {
|
} else if (data.menuLabel === 'File.Add file to project') {
|
||||||
|