feat: customize speckle-server for ATAD - auth bypass, file upload, frontend cleanup
Release pipeline / Get version (push) Has been cancelled
Release pipeline / Get Chart Name (push) Has been cancelled
Release pipeline / tests (push) Has been cancelled
Release pipeline / builds (push) Has been cancelled
Release pipeline / builds-ghcr (push) Has been cancelled
Release pipeline / test-deployments (push) Has been cancelled
Release pipeline / deploy (push) Has been cancelled
Release pipeline / Helm chart oci (push) Has been cancelled
Release pipeline / npm (push) Has been cancelled
Release pipeline / snyk (push) Has been cancelled
Release pipeline / Get version (push) Has been cancelled
Release pipeline / Get Chart Name (push) Has been cancelled
Release pipeline / tests (push) Has been cancelled
Release pipeline / builds (push) Has been cancelled
Release pipeline / builds-ghcr (push) Has been cancelled
Release pipeline / test-deployments (push) Has been cancelled
Release pipeline / deploy (push) Has been cancelled
Release pipeline / Helm chart oci (push) Has been cancelled
Release pipeline / npm (push) Has been cancelled
Release pipeline / snyk (push) Has been cancelled
This commit is contained in:
@@ -24,3 +24,5 @@ readme.md
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
packages/ui-components/node_modules_old
|
||||
|
||||
@@ -90,10 +90,3 @@ packages/*/.tshy/
|
||||
.nuxt
|
||||
.output
|
||||
.gitnexus
|
||||
|
||||
|
||||
|
||||
backend.log
|
||||
packages/server/backend_crash.log
|
||||
packages/server/server_log*.txt
|
||||
|
||||
|
||||
@@ -15,3 +15,10 @@ Thumbs.db
|
||||
*.zip
|
||||
*.7z
|
||||
.gitnexus
|
||||
|
||||
# Auto-ignored files > 5MB
|
||||
.yarn/cache/@cloudflare-workerd-linux-64-npm-1.20240405.0-d2b634426b-10.zip
|
||||
.yarn/cache/@img-sharp-libvips-linux-x64-npm-1.2.3-a27534bdc9-10.zip
|
||||
.yarn/cache/@swc-core-linux-x64-gnu-npm-1.5.7-54071e635c-10.zip
|
||||
.yarn/cache/@swc-core-linux-x64-gnu-npm-1.9.3-693a0e8064-10.zip
|
||||
.yarn/cache/three-npm-0.140.2-944041dff4-10.zip
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Hello world from test2
|
||||
Vendored
+22
@@ -10,6 +10,28 @@
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:3033",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Speckle Server",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["${workspaceFolder}/.yarn/releases/yarn-4.5.0.cjs", "workspace", "@speckle/server", "dev"],
|
||||
"console": "integratedTerminal",
|
||||
"restart": true,
|
||||
"autoAttachChildProcesses": true,
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"name": "Attach to Speckle Server (WSL)",
|
||||
"port": 9229,
|
||||
"address": "localhost",
|
||||
"localRoot": "${workspaceFolder}/packages/server",
|
||||
"remoteRoot": "/mnt/e/speckle-server/packages/server",
|
||||
"restart": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,9 +1,7 @@
|
||||
compressionLevel: mixed
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableMirror: false
|
||||
|
||||
enableScripts: true
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.5.0.cjs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **speckle-server** (175 symbols, 160 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **speckle-server** (182 symbols, 160 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **speckle-server** (175 symbols, 160 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **speckle-server** (182 symbols, 160 relationships, 0 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@echo off
|
||||
set PATH=%USERPROFILE%\.fnm;%PATH%
|
||||
for /f "tokens=*" %%i in ('fnm env --use-on-cd') do %%i
|
||||
echo Node version:
|
||||
node --version
|
||||
echo.
|
||||
set NODE_OPTIONS=--max-old-space-size=8192
|
||||
echo Running yarn install...
|
||||
node .yarn\releases\yarn-4.5.0.cjs install --inline-builds 2>&1
|
||||
echo.
|
||||
echo Exit code: %ERRORLEVEL%
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
export PATH=$(echo $PATH | tr ':' '\n' | grep -v "/mnt/c/" | grep -v "/mnt/d/" | tr '\n' ':' | sed 's/:$//')
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
echo "Yarn Install inside WSL..."
|
||||
yarn install
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"packageManager": "yarn@4.5.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "^22.17.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "yarn workspaces foreach --parallel --topological --verbose --worktree run build",
|
||||
"build:public": "yarn ensure:tailwind-deps && yarn workspace @speckle/frontend-2 build:postinstall && yarn workspaces foreach --parallel --topological --verbose --worktree --no-private run build",
|
||||
"build:tailwind-deps": "yarn workspaces foreach --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/shared,@speckle/tailwind-theme,@speckle/ui-components}' run build",
|
||||
"ensure:tailwind-deps": "node ./utils/ensure-tailwind-deps.mjs",
|
||||
"helm:readme:generate": "./utils/helm/update-schema-json.sh",
|
||||
"prettier:check": "prettier --check .",
|
||||
"prettier:fix": "prettier --write .",
|
||||
"prettier:fix:file": "prettier --write",
|
||||
"circleci:check": "circleci config validate ./.circleci/config.yml",
|
||||
"dev:docker": "docker compose -f ./docker-compose-deps.yml",
|
||||
"dev:docker:up": "docker compose -f ./docker-compose-deps.yml up -d",
|
||||
"dev:docker:down": "docker compose -f ./docker-compose-deps.yml down",
|
||||
"dev:docker:down:volumes": "docker compose -f ./docker-compose-deps.yml down --volumes",
|
||||
"dev:docker:restart": "yarn dev:docker:down && yarn dev:docker:up",
|
||||
"dev:kind:up": "ctlptl apply --filename ./tests/deployment/helm/cluster-config.yaml",
|
||||
"dev:kind:down": "ctlptl delete -f ./tests/deployment/helm/cluster-config.yaml",
|
||||
"dev:kind:helm:up": "yarn dev:kind:up && tilt up --file ./tests/deployment/helm/Tiltfile --context kind-speckle-server",
|
||||
"dev:kind:helm:down": "tilt down --file ./tests/deployment/helm/Tiltfile --context kind-speckle-server",
|
||||
"dev:kind:helm:ci": "tilt ci --file ./tests/deployment/helm/Tiltfile --context kind-speckle-server --timeout 10m",
|
||||
"dev": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
|
||||
"dev:no-server": "yarn workspaces foreach --exclude @speckle/server --parallel --interlaced --verbose --worktree --jobs unlimited run dev",
|
||||
"dev:minimal": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run dev",
|
||||
"gqlgen": "yarn workspaces foreach --parallel --interlaced --verbose --worktree --jobs unlimited --include '{@speckle/server,@speckle/frontend-2}' run gqlgen",
|
||||
"dev:server": "yarn workspace @speckle/server dev",
|
||||
"dev:frontend-2": "yarn workspace @speckle/frontend-2 dev",
|
||||
"dev:shared": "yarn workspace @speckle/shared dev",
|
||||
"dev:ifc-import-service": "./packages/ifc-import-service/run.sh",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "husky install",
|
||||
"cm": "cz",
|
||||
"eslint:inspect": "eslint-config-inspector",
|
||||
"eslint:projectwide": "node ./utils/eslint-projectwide.mjs",
|
||||
"helm:jsonschema:generate": "./utils/helm/update-schema-json.sh",
|
||||
"npkill": "npkill"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/config-inspector": "^0.4.10",
|
||||
"@eslint/js": "^9.4.0",
|
||||
"@rollup/plugin-typescript": "^11.1.0",
|
||||
"@swc/core": "^1.2.222",
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/lockfile": "^1.0.2",
|
||||
"commitizen": "^4.2.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-flat-config-utils": "^0.2.5",
|
||||
"globals": "^15.4.0",
|
||||
"husky": "^7.0.4",
|
||||
"lint-staged": "^12.3.7",
|
||||
"lockfile": "^1.0.4",
|
||||
"npkill": "^0.12.2",
|
||||
"pino-pretty": "^9.1.1",
|
||||
"prettier": "^2.5.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.0.0",
|
||||
"vitest": "^3.0.7",
|
||||
"zx": "^8.1.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"@aws-sdk/client-sts/fast-xml-parser": ">=4.2.5",
|
||||
"@aws-sdk/client-s3/fast-xml-parser": ">=4.2.5",
|
||||
"@bull-board/express/express": "^4.20.0",
|
||||
"@microsoft/api-extractor/semver": "^7.5.4",
|
||||
"@rushstack/node-core-library/semver": "^7.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
"@types/react": "file:./packages/frontend-2/type-augmentations/stubs/types__react",
|
||||
"core-js": "3.22.4",
|
||||
"core-js-compat/semver": "^7.5.4",
|
||||
"bull-board/ejs": "^3.1.10",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"mocha/serialize-javascript": ">=6.0.2",
|
||||
"prettier": "^2.8.7",
|
||||
"puppeteer-core/ws": "^8.17.1",
|
||||
"request/tough-cookie": ">=4.1.3",
|
||||
"rollup-plugin-terser/serialize-javascript": ">=6.0.2",
|
||||
"simple-update-notifier/semver": "^7.5.4",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"wait-on": ">=7.2.0",
|
||||
"vitest": "^3.0.7",
|
||||
"@types/node": "22.16.2",
|
||||
"bull-board/express": "^4.20.0",
|
||||
"express/path-to-regexp": "^0.1.12"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "cz-conventional-changelog"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"node-gyp": "^11.4.2"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div class="relative min-h-full">
|
||||
<ClientOnly>
|
||||
<HeaderNavBar v-if="!isEmbedEnabled" class="relative z-20" />
|
||||
</ClientOnly>
|
||||
<main class="absolute top-0 left-0 z-10 h-[100dvh] w-screen">
|
||||
<slot />
|
||||
</main>
|
||||
@@ -10,6 +7,4 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
|
||||
|
||||
const { isEnabled: isEmbedEnabled } = useEmbed()
|
||||
</script>
|
||||
|
||||
@@ -14,78 +14,6 @@ import { useMiddlewareQueryFetchPolicy } from '~/lib/core/composables/navigation
|
||||
* Used in project page to validate that project ID refers to a valid project and redirects to 404 if not
|
||||
*/
|
||||
export default defineParallelizedNuxtRouteMiddleware(async (to, from) => {
|
||||
const projectId = to.params.id as string
|
||||
|
||||
// Check if embed token is present in URL
|
||||
const embedToken = to.query.embedToken as Optional<string>
|
||||
|
||||
// Skip middleware validation for embed tokens - let the auth system handle them
|
||||
if (embedToken) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = useApolloClientFromNuxt()
|
||||
const { setActiveWorkspace } = useSetActiveWorkspace()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const fetchPolicy = useMiddlewareQueryFetchPolicy()
|
||||
|
||||
const { data, errors } = await client
|
||||
.query({
|
||||
query: projectAccessCheckQuery,
|
||||
variables: { id: projectId },
|
||||
context: {
|
||||
skipLoggingErrors: true
|
||||
},
|
||||
fetchPolicy: fetchPolicy(to, from)
|
||||
})
|
||||
.catch(convertThrowIntoFetchResult)
|
||||
|
||||
// we may not even get to the authResult because of project() resolver errors, hence the mapping
|
||||
// from errors to authResult
|
||||
const authResult = data?.project.permissions.canRead || errorsToAuthResult({ errors })
|
||||
if (!authResult.authorized) {
|
||||
switch (authResult.code) {
|
||||
case WorkspaceSsoErrorCodes.SESSION_MISSING_OR_EXPIRED: {
|
||||
// Redirect to the SSO error page
|
||||
const payload = authResult.payload as Optional<{
|
||||
workspaceSlug: string
|
||||
}>
|
||||
const workspaceSlug = payload?.workspaceSlug
|
||||
if (workspaceSlug) {
|
||||
return navigateTo(`/workspaces/${workspaceSlug}/sso/session-error`)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case 'FORBIDDEN':
|
||||
return abortNavigation(
|
||||
createError({
|
||||
statusCode: 403,
|
||||
message: authResult.message
|
||||
})
|
||||
)
|
||||
case 'STREAM_NOT_FOUND':
|
||||
return abortNavigation(
|
||||
createError({
|
||||
statusCode: 404,
|
||||
message: authResult.message
|
||||
})
|
||||
)
|
||||
default:
|
||||
return abortNavigation(
|
||||
createError({
|
||||
statusCode: 500,
|
||||
message: authResult.message
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isLoggedIn.value &&
|
||||
isWorkspacesEnabled.value &&
|
||||
data?.activeUser?.activeWorkspace?.id !== data?.project.workspaceId
|
||||
) {
|
||||
await setActiveWorkspace({ id: data?.project.workspaceId })
|
||||
}
|
||||
// Bypass requireValidProject validation entirely for public viewer lite
|
||||
return
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"@nuxt/devtools": "^1.7.0",
|
||||
"@nuxt/eslint": "^1.1.0",
|
||||
"@nuxt/image": "^1.8.1",
|
||||
"@nuxt/kit": "^4.4.2",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@parcel/watcher": "^2.5.1",
|
||||
"@speckle/tailwind-theme": "workspace:^",
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-20 flex flex-col items-center">
|
||||
<h1 class="h1 font-medium mb-14">Notice</h1>
|
||||
<div class="flex flex-col space-y-24 md:space-y-0 md:flex-row md:items-stretch">
|
||||
<div class="w-full md:w-72 lg:w-96 flex flex-col space-y-6 justify-between">
|
||||
<div class="text-heading">
|
||||
You've previously registered with your
|
||||
<strong>e-mail address</strong>
|
||||
and a
|
||||
<strong>password.</strong>
|
||||
Please use those credentials to log in.
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<FormButton size="lg" :icon-left="ArrowLeftIcon" :to="loginRoute">
|
||||
Go to login
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden md:block border border-foreground my-4 mx-6" />
|
||||
<div class="w-full md:w-72 lg:w-96 flex flex-col space-y-6 justify-between">
|
||||
<div class="text-heading">
|
||||
After
|
||||
<strong>verifying</strong>
|
||||
your e-mail address you can use also use the connected account to log in.
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<FormButton
|
||||
v-if="hasEmail"
|
||||
size="lg"
|
||||
:icon-left="PaperAirplaneIcon"
|
||||
:disabled="resendVerificationEmailLoading"
|
||||
@click="onResend"
|
||||
>
|
||||
Verify email
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PaperAirplaneIcon } from '@heroicons/vue/24/outline'
|
||||
import { ArrowLeftIcon } from '@heroicons/vue/24/solid'
|
||||
import type { Optional } from '@speckle/shared'
|
||||
import { ValidationHelpers } from '@speckle/ui-components'
|
||||
import { useMutation } from '@vue/apollo-composable'
|
||||
import { requestVerificationByEmailMutation } from '~/lib/auth/graphql/mutations'
|
||||
import { useGlobalToast, ToastNotificationType } from '~/lib/common/composables/toast'
|
||||
import {
|
||||
convertThrowIntoFetchResult,
|
||||
getFirstErrorMessage
|
||||
} from '~/lib/common/helpers/graphql'
|
||||
import { loginRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'guest'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { mutate: resendVerificationEmail, loading: resendVerificationEmailLoading } =
|
||||
useMutation(requestVerificationByEmailMutation)
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const email = computed(() => route.query.email as Optional<string>)
|
||||
const hasEmail = computed(
|
||||
() => !!email.value?.length && ValidationHelpers.VALID_EMAIL.exec(email.value)
|
||||
)
|
||||
|
||||
const onResend = async () => {
|
||||
const emailAddress = email.value
|
||||
if (!emailAddress || !hasEmail.value) return
|
||||
|
||||
const res = await resendVerificationEmail({ email: emailAddress }).catch(
|
||||
convertThrowIntoFetchResult
|
||||
)
|
||||
if (res?.data?.requestVerificationByEmail) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Verification email (re-)sent successfully'
|
||||
})
|
||||
} else {
|
||||
const errMsg = getFirstErrorMessage(res?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Error sending verification email',
|
||||
description: errMsg
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,160 +0,0 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-[864px]">
|
||||
<CommonLoadingBar :loading="loading" />
|
||||
<template v-if="!loading && fn">
|
||||
<AutomateFunctionPageHeader
|
||||
:fn="fn"
|
||||
:fn-workspace="fnWorkspace"
|
||||
:is-owner="isOwner"
|
||||
class="mb-12"
|
||||
@create-automation="showNewAutomationDialog = true"
|
||||
@edit="showEditDialog = true"
|
||||
/>
|
||||
<AutomateFunctionPageInfo
|
||||
:fn="fn"
|
||||
@create-automation="showNewAutomationDialog = true"
|
||||
/>
|
||||
<AutomateAutomationCreateDialog
|
||||
v-model:open="showNewAutomationDialog"
|
||||
:preselected-function="fn"
|
||||
:workspace-id="fnWorkspaceId"
|
||||
/>
|
||||
<AutomateFunctionEditDialog
|
||||
v-if="editModel"
|
||||
v-model:open="showEditDialog"
|
||||
:model="editModel"
|
||||
:workspaces="activeUserWorkspaces"
|
||||
:fn-id="fn.id"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { SourceApps, type Optional } from '@speckle/shared'
|
||||
import { CommonLoadingBar } from '@speckle/ui-components'
|
||||
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
|
||||
import type { FunctionDetailsFormValues } from '~/lib/automate/helpers/functions'
|
||||
import { useQueryLoaded } from '~/lib/common/composables/graphql'
|
||||
import { useMarkdown } from '~/lib/common/composables/markdown'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
|
||||
graphql(`
|
||||
fragment AutomateFunctionPage_AutomateFunction on AutomateFunction {
|
||||
id
|
||||
name
|
||||
description
|
||||
logo
|
||||
supportedSourceApps
|
||||
tags
|
||||
...AutomateFunctionPageHeader_Function
|
||||
...AutomateFunctionPageInfo_AutomateFunction
|
||||
...AutomateAutomationCreateDialog_AutomateFunction
|
||||
creator {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const pageQuery = graphql(`
|
||||
query AutomateFunctionPage($functionId: ID!) {
|
||||
automateFunction(id: $functionId) {
|
||||
...AutomateFunctionPage_AutomateFunction
|
||||
}
|
||||
activeUser {
|
||||
workspaces {
|
||||
items {
|
||||
...AutomateFunctionCreateDialog_Workspace
|
||||
...AutomateFunctionEditDialog_Workspace
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const functionWorkspaceQuery = graphql(`
|
||||
query AutomateFunctionPageWorkspace($workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
id
|
||||
...AutomateFunctionPageHeader_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'require-valid-function']
|
||||
})
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const route = useRoute()
|
||||
const functionId = computed(() => route.params.fid as string)
|
||||
const loading = useQueryLoading()
|
||||
const { result, onResult } = useQuery(
|
||||
pageQuery,
|
||||
() => ({
|
||||
functionId: functionId.value
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
|
||||
const queryLoadedOnce = useQueryLoaded({ onResult })
|
||||
const showEditDialog = ref(false)
|
||||
const showNewAutomationDialog = ref(false)
|
||||
|
||||
const fn = computed(() => result.value?.automateFunction)
|
||||
const fnWorkspaceId = computed(() => fn.value?.workspaceIds?.at(0))
|
||||
|
||||
const { result: functionWorkspaceResult } = useQuery(
|
||||
functionWorkspaceQuery,
|
||||
() => ({
|
||||
workspaceId: fnWorkspaceId.value as string
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!fnWorkspaceId.value
|
||||
})
|
||||
)
|
||||
|
||||
const fnWorkspace = computed(() => functionWorkspaceResult.value?.workspace)
|
||||
|
||||
const isOwner = computed(
|
||||
() =>
|
||||
!!(
|
||||
activeUser.value?.id &&
|
||||
fn.value?.creator &&
|
||||
activeUser.value.id === fn.value.creator.id
|
||||
)
|
||||
)
|
||||
const activeUserWorkspaces = computed(
|
||||
() => result.value?.activeUser?.workspaces.items ?? []
|
||||
)
|
||||
|
||||
const { html: plaintextDescription } = useMarkdown(
|
||||
computed(() => fn.value?.description || ''),
|
||||
{ plaintext: true, waitFor: queryLoadedOnce }
|
||||
)
|
||||
|
||||
const editModel = computed((): Optional<FunctionDetailsFormValues> => {
|
||||
const func = fn.value
|
||||
if (!func) return undefined
|
||||
|
||||
return {
|
||||
name: func.name,
|
||||
description: func.description,
|
||||
image: func.logo,
|
||||
allowedSourceApps: SourceApps.filter((app) =>
|
||||
func.supportedSourceApps.includes(app.name)
|
||||
),
|
||||
tags: func.tags,
|
||||
workspace: activeUserWorkspaces.value.find(
|
||||
(workspace) => workspace.id === fnWorkspaceId.value
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: computed(() => fn.value?.name || 'Function'),
|
||||
description: plaintextDescription
|
||||
})
|
||||
</script>
|
||||
@@ -1,11 +1,226 @@
|
||||
<template>
|
||||
<div />
|
||||
<div class="min-h-screen flex items-center justify-center bg-foundation-page p-4">
|
||||
<div class="bg-foundation p-8 rounded-xl shadow-lg border border-primary-muted max-w-lg w-full">
|
||||
<h1 class="text-3xl font-bold mb-4 text-center text-primary">Speckle Viewer Lite</h1>
|
||||
<p class="text-sm text-foreground-2 mb-8 text-center">
|
||||
Upload an IFC file to instantly convert and view it in 3D.
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-10 flex flex-col items-center justify-center transition-colors text-center cursor-pointer"
|
||||
:class="isDragging ? 'border-primary bg-primary/10' : 'border-outline-2 hover:border-primary-muted'"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="!uploadStatus && fileInput.click()"
|
||||
>
|
||||
<template v-if="uploadStatus">
|
||||
<div class="text-primary font-medium animate-pulse">{{ uploadStatus }}</div>
|
||||
<div v-if="progress > 0" class="w-full bg-outline-3 rounded-full mt-4 h-2 max-w-xs transition-all overflow-hidden relative">
|
||||
<div class="bg-primary h-full transition-all" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Using standard SVG to minimize dependencies on unimported icons -->
|
||||
<svg class="h-10 w-10 text-primary mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<div class="font-medium text-foreground">Click to upload or drag and drop</div>
|
||||
<p class="text-xs text-foreground-2 mt-2">IFC files only</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".ifc"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
|
||||
<div v-if="errorMsg" class="mt-4 p-4 bg-danger-muted text-danger rounded-lg text-sm text-center">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Dashboard' })
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNuxtApp } from '#app'
|
||||
import { gql } from '@apollo/client/core'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'dashboard-redirect']
|
||||
})
|
||||
definePageMeta({ layout: 'empty' })
|
||||
|
||||
const router = useRouter()
|
||||
const isDragging = ref(false)
|
||||
const uploadStatus = ref('')
|
||||
const progress = ref(0)
|
||||
const fileInput = ref()
|
||||
const errorMsg = ref('')
|
||||
|
||||
const { $apollo } = useNuxtApp()
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragging.value = false
|
||||
if (uploadStatus.value) return
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
const onFileSelect = (e: Event) => {
|
||||
if (uploadStatus.value) return
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
handleFile(file)
|
||||
// reset input so the same file could be selected again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (!file.name.toLowerCase().endsWith('.ifc')) {
|
||||
errorMsg.value = 'Please upload a valid .ifc file.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
errorMsg.value = ''
|
||||
uploadStatus.value = 'Creating Bucket...'
|
||||
progress.value = 5
|
||||
|
||||
// 1. Create a public project
|
||||
const projectMut = await $apollo.defaultClient.mutate({
|
||||
mutation: gql`
|
||||
mutation ProjectCreate($input: ProjectCreateInput!) {
|
||||
projectMutations {
|
||||
create(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
name: file.name.substring(0, file.name.lastIndexOf('.')),
|
||||
description: "Public Upload",
|
||||
visibility: "PUBLIC"
|
||||
}
|
||||
}
|
||||
})
|
||||
const projectId = projectMut.data.projectMutations.create.id
|
||||
|
||||
// 2. Generate Upload URL
|
||||
uploadStatus.value = 'Preparing upload...'
|
||||
progress.value = 10
|
||||
|
||||
const uploadUrlMut = await $apollo.defaultClient.mutate({
|
||||
mutation: gql`
|
||||
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
|
||||
fileUploadMutations {
|
||||
generateUploadUrl(input: $input) {
|
||||
fileId
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
projectId,
|
||||
fileName: file.name
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { url, fileId } = uploadUrlMut.data.fileUploadMutations.generateUploadUrl
|
||||
|
||||
// 3. Create Model Branch
|
||||
uploadStatus.value = 'Creating model...'
|
||||
progress.value = 15
|
||||
const modelMut = await $apollo.defaultClient.mutate({
|
||||
mutation: gql`
|
||||
mutation ModelCreate($input: CreateModelInput!) {
|
||||
modelMutations {
|
||||
create(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
projectId,
|
||||
name: "Upload"
|
||||
}
|
||||
}
|
||||
})
|
||||
const modelId = modelMut.data.modelMutations.create.id
|
||||
|
||||
// 4. Upload File directly to Presigned URL
|
||||
uploadStatus.value = 'Uploading file...'
|
||||
progress.value = 20
|
||||
|
||||
const etag = await new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('PUT', url, true)
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progress.value = 20 + Math.floor((e.loaded / e.total) * 70) // up to 90%
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
progress.value = 90
|
||||
resolve(xhr.getResponseHeader('ETag') || '"none"') // Fallback if Minio strips Etag from headers without CORS
|
||||
} else {
|
||||
reject(new Error('Upload failed with status: ' + xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('Network error during upload.'))
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
// 5. Start File Import
|
||||
uploadStatus.value = 'Finalizing conversion...'
|
||||
progress.value = 95
|
||||
|
||||
await $apollo.defaultClient.mutate({
|
||||
mutation: gql`
|
||||
mutation StartFileImport($input: StartFileImportInput!) {
|
||||
fileUploadMutations {
|
||||
startFileImport(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
projectId,
|
||||
modelId,
|
||||
fileId,
|
||||
etag,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
uploadStatus.value = 'Redirecting to viewer...'
|
||||
progress.value = 100
|
||||
|
||||
setTimeout(() => {
|
||||
router.push(`/projects/${projectId}/models/${modelId}`)
|
||||
}, 500)
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
errorMsg.value = error.message || 'An error occurred during upload. Check console.'
|
||||
uploadStatus.value = ''
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock no-link />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<div class="flex gap-2 items-center">
|
||||
<FormButton
|
||||
v-if="!isOnboardingForced"
|
||||
class="opacity-70 hover:opacity-100 p-1"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
@click="onSkip"
|
||||
>
|
||||
Skip
|
||||
</FormButton>
|
||||
<FormButton color="outline" @click="() => logout({ skipRedirect: false })">
|
||||
Sign out
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col items-center justify-center p-4 max-w-lg mx-auto">
|
||||
<h1 class="text-heading-xl text-foreground mb-2 font-normal">
|
||||
Tell us about yourself
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground-2 mb-8">
|
||||
Your answers will help us improve
|
||||
</p>
|
||||
<OnboardingQuestionsForm />
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useProcessOnboarding } from '~~/lib/auth/composables/onboarding'
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { homeRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
useHead({
|
||||
title: 'Welcome to Speckle'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
const isOnboardingForced = useIsOnboardingForced()
|
||||
const { setUserOnboardingComplete } = useProcessOnboarding()
|
||||
const { logout } = useAuthManager()
|
||||
|
||||
const onSkip = () => {
|
||||
setUserOnboardingComplete()
|
||||
navigateTo(homeRoute)
|
||||
}
|
||||
</script>
|
||||
@@ -1,376 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="project">
|
||||
<div v-if="invite" class="mb-4">
|
||||
<ProjectsInviteBanner
|
||||
:invite="invite"
|
||||
:show-project-name="false"
|
||||
@processed="onInviteAccepted"
|
||||
/>
|
||||
</div>
|
||||
<ProjectsMoveToWorkspaceAlert
|
||||
v-if="shouldShowWorkspaceAlert"
|
||||
:disable-button="disableLegacyMoveProjectButton"
|
||||
:project-id="project.id"
|
||||
@move-project="onMoveProject"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:justify-between md:items-center gap-6 mb-6"
|
||||
>
|
||||
<ProjectPageHeader :project="project" />
|
||||
<div class="flex gap-x-3 items-center justify-between">
|
||||
<div class="flex flex-row gap-x-3">
|
||||
<CommonBadge v-if="project.role" rounded color="secondary">
|
||||
{{ RoleInfo.Stream[project.role as StreamRoles].title }}
|
||||
</CommonBadge>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-3">
|
||||
<div v-tippy="collaboratorsTooltip">
|
||||
<NuxtLink
|
||||
:to="
|
||||
canReadSettings?.authorized
|
||||
? projectRoute(project.id, 'collaborators')
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<UserAvatarGroup
|
||||
:users="teamUsers"
|
||||
:max-count="2"
|
||||
class="max-w-[104px]"
|
||||
hide-tooltips
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<LayoutMenu
|
||||
v-model:open="showActionsMenu"
|
||||
:items="actionsItems"
|
||||
:menu-position="HorizontalDirection.Left"
|
||||
:menu-id="menuId"
|
||||
@click.stop.prevent
|
||||
@chosen="onActionChosen"
|
||||
>
|
||||
<FormButton
|
||||
color="subtle"
|
||||
hide-text
|
||||
:icon-right="EllipsisHorizontalIcon"
|
||||
@click="showActionsMenu = !showActionsMenu"
|
||||
/>
|
||||
</LayoutMenu>
|
||||
</div>
|
||||
</div>
|
||||
<LayoutTabsHorizontal v-model:active-item="activePageTab" :items="pageTabItems">
|
||||
<NuxtPage :project="project" />
|
||||
</LayoutTabsHorizontal>
|
||||
</div>
|
||||
|
||||
<WorkspaceMoveProject
|
||||
v-if="project && isWorkspacesEnabled"
|
||||
v-model:open="showMoveDialog"
|
||||
event-source="project-page"
|
||||
:project="project"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { Roles, type Optional, RoleInfo, type StreamRoles } from '@speckle/shared'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { projectPageQuery } from '~~/lib/projects/graphql/queries'
|
||||
import { useGeneralProjectPageUpdateTracking } from '~~/lib/projects/composables/projectPages'
|
||||
import { LayoutTabsHorizontal, type LayoutPageTabItem } from '@speckle/ui-components'
|
||||
import { projectRoute, projectWebhooksRoute } from '~/lib/common/helpers/route'
|
||||
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
|
||||
import { EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
import { useCopyProjectLink } from '~~/lib/projects/composables/projectManagement'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageProject on Project {
|
||||
id
|
||||
createdAt
|
||||
modelCount: models(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
commentThreadCount: commentThreads(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
workspace {
|
||||
id
|
||||
permissions {
|
||||
canListDashboards {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions {
|
||||
canReadSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canUpdate {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canMoveToWorkspace {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
...ProjectPageTeamInternals_Project
|
||||
...ProjectPageProjectHeader
|
||||
...ProjectPageTeamDialog
|
||||
...WorkspaceMoveProjectManager_ProjectBase
|
||||
...ProjectPageSettingsTab_Project
|
||||
...WorkspaceMoveProject_Project
|
||||
hasAccessToDashboards: hasAccessToFeature(featureName: dashboards)
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
'require-valid-project',
|
||||
function (to) {
|
||||
// Redirect from /projects/:id/models to /projects/:id
|
||||
const projectId = to.params.id as string
|
||||
if (/\/models\/?$/i.test(to.path)) {
|
||||
return navigateTo(projectRoute(projectId))
|
||||
}
|
||||
|
||||
// Redirect from /projects/:id/webhooks to /projects/:id/settings/webhooks
|
||||
if (/\/projects\/\w*?\/webhooks/i.test(to.path)) {
|
||||
return navigateTo(projectWebhooksRoute(projectId))
|
||||
}
|
||||
}
|
||||
],
|
||||
alias: ['/projects/:id/models', '/projects/:id/webhooks']
|
||||
})
|
||||
|
||||
enum ActionTypes {
|
||||
CopyLink = 'copy-link',
|
||||
Move = 'move'
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const copyProjectLink = useCopyProjectLink()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
useGeneralProjectPageUpdateTracking({ projectId }, { notifyOnProjectUpdate: true })
|
||||
const { result: projectPageResult } = useQuery(
|
||||
projectPageQuery,
|
||||
() => ({
|
||||
id: projectId.value,
|
||||
...(token.value?.length ? { token: token.value } : {})
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
|
||||
const showActionsMenu = ref(false)
|
||||
const menuId = useId()
|
||||
const showMoveDialog = ref(false)
|
||||
|
||||
const project = computed(() => projectPageResult.value?.project)
|
||||
const invite = computed(() => projectPageResult.value?.projectInvite || undefined)
|
||||
const projectName = computed(() =>
|
||||
project.value?.name.length ? project.value.name : ''
|
||||
)
|
||||
const modelCount = computed(() => project.value?.modelCount.totalCount)
|
||||
const commentCount = computed(() => project.value?.commentThreadCount.totalCount)
|
||||
const canListDashboards = computed(
|
||||
() => project.value?.workspace?.permissions.canListDashboards.authorized
|
||||
)
|
||||
const canReadSettings = computed(() => project.value?.permissions.canReadSettings)
|
||||
|
||||
const canUpdate = computed(() => project.value?.permissions.canUpdate)
|
||||
const hasRole = computed(() => project.value?.role)
|
||||
const teamUsers = computed(() => project.value?.team.map((t) => t.user) || [])
|
||||
const actionsItems = computed<LayoutMenuItem[][]>(() => {
|
||||
const items: LayoutMenuItem[][] = [
|
||||
[
|
||||
{
|
||||
title: 'Copy link',
|
||||
id: ActionTypes.CopyLink
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
if (isWorkspacesEnabled.value && !project.value?.workspace?.id && hasRole.value) {
|
||||
items.push([
|
||||
{
|
||||
title: 'Move project...',
|
||||
id: ActionTypes.Move,
|
||||
disabled: !isOwner.value,
|
||||
disabledTooltip: 'Only the project owner can move this project into a workspace'
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: projectName,
|
||||
meta: [
|
||||
{
|
||||
name: 'robots',
|
||||
content: 'noindex, nofollow'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const onInviteAccepted = async (params: { accepted: boolean }) => {
|
||||
if (params.accepted) {
|
||||
await router.replace({
|
||||
query: { ...route.query, accept: undefined, token: undefined }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isOwner = computed(() => project.value?.role === Roles.Stream.Owner)
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const pageTabItems = computed((): LayoutPageTabItem[] => {
|
||||
const items: LayoutPageTabItem[] = [
|
||||
{
|
||||
title: 'Models',
|
||||
id: 'models',
|
||||
count: modelCount.value
|
||||
},
|
||||
{
|
||||
title: 'Discussions',
|
||||
id: 'discussions',
|
||||
count: commentCount.value
|
||||
}
|
||||
]
|
||||
|
||||
if (
|
||||
isAutomateEnabled.value &&
|
||||
project.value?.workspace &&
|
||||
project.value?.workspace?.role !== Roles.Workspace.Guest
|
||||
) {
|
||||
items.push({
|
||||
title: 'Automations',
|
||||
id: 'automations'
|
||||
})
|
||||
}
|
||||
|
||||
if (canReadSettings.value?.authorized) {
|
||||
items.push({
|
||||
title: 'Collaborators',
|
||||
id: 'collaborators'
|
||||
})
|
||||
|
||||
items.push({
|
||||
title: 'Settings',
|
||||
id: 'settings'
|
||||
})
|
||||
}
|
||||
|
||||
if (project.value?.hasAccessToDashboards && canListDashboards.value) {
|
||||
items.push({
|
||||
title: 'Dashboards',
|
||||
id: 'dashboards'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const findTabById = (id: string) =>
|
||||
pageTabItems.value.find((tab) => tab.id === id) || pageTabItems.value[0]
|
||||
|
||||
const collaboratorsTooltip = computed(() =>
|
||||
canReadSettings.value?.authorized
|
||||
? canUpdate.value?.authorized
|
||||
? 'Manage collaborators'
|
||||
: 'View collaborators'
|
||||
: null
|
||||
)
|
||||
|
||||
const activePageTab = computed({
|
||||
get: () => {
|
||||
const path = router.currentRoute.value.path
|
||||
if (/\/discussions\/?$/i.test(path)) return findTabById('discussions')
|
||||
if (/\/automations\/?.*$/i.test(path)) return findTabById('automations')
|
||||
if (/\/acc\/?.*$/i.test(path)) return findTabById('acc')
|
||||
if (/\/dashboards\/?/i.test(path)) return findTabById('dashboards')
|
||||
if (/\/collaborators\/?/i.test(path) && canReadSettings.value?.authorized)
|
||||
return findTabById('collaborators')
|
||||
if (/\/settings\/?/i.test(path) && canReadSettings.value?.authorized)
|
||||
return findTabById('settings')
|
||||
return findTabById('models')
|
||||
},
|
||||
set: (val: LayoutPageTabItem) => {
|
||||
if (!val) return
|
||||
switch (val.id) {
|
||||
case 'models':
|
||||
router.push({ path: projectRoute(projectId.value, 'models') })
|
||||
break
|
||||
case 'discussions':
|
||||
router.push({ path: projectRoute(projectId.value, 'discussions') })
|
||||
break
|
||||
case 'acc':
|
||||
router.push({ path: projectRoute(projectId.value, 'acc') })
|
||||
break
|
||||
case 'automations':
|
||||
router.push({ path: projectRoute(projectId.value, 'automations') })
|
||||
break
|
||||
case 'collaborators':
|
||||
if (canReadSettings.value?.authorized) {
|
||||
router.push({ path: projectRoute(projectId.value, 'collaborators') })
|
||||
}
|
||||
break
|
||||
case 'dashboards':
|
||||
if (project.value?.hasAccessToDashboards) {
|
||||
router.push({ path: projectRoute(projectId.value, 'dashboards') })
|
||||
}
|
||||
break
|
||||
case 'settings':
|
||||
if (canReadSettings.value?.authorized) {
|
||||
router.push({ path: projectRoute(projectId.value, 'settings') })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const shouldShowWorkspaceAlert = computed(
|
||||
() =>
|
||||
isWorkspacesEnabled.value &&
|
||||
isLoggedIn.value &&
|
||||
!project.value?.workspace &&
|
||||
hasRole.value
|
||||
)
|
||||
|
||||
const disableLegacyMoveProjectButton = computed(
|
||||
() => !project.value?.permissions.canMoveToWorkspace.authorized
|
||||
)
|
||||
|
||||
const onMoveProject = () => {
|
||||
mixpanel.track('Move Project CTA Clicked', {
|
||||
location: 'project'
|
||||
})
|
||||
showMoveDialog.value = true
|
||||
}
|
||||
|
||||
const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) => {
|
||||
const { item } = params
|
||||
|
||||
switch (item.id) {
|
||||
case ActionTypes.CopyLink:
|
||||
copyProjectLink(projectId.value)
|
||||
break
|
||||
case ActionTypes.Move:
|
||||
onMoveProject()
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<div><NuxtPage /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'require-valid-automation']
|
||||
})
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageProjectFragment
|
||||
}
|
||||
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Automations | ${projectName.value}`
|
||||
})
|
||||
</script>
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<div v-if="automation && project" class="flex flex-col gap-8 items-start">
|
||||
<ProjectPageAutomationHeader
|
||||
:automation="automation"
|
||||
:project="project"
|
||||
:is-editable="isEditable"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6 w-full">
|
||||
<div
|
||||
class="col-span-1 grid gap-6 mb-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1 auto-rows-min"
|
||||
>
|
||||
<ProjectPageAutomationTestAutomationInfo
|
||||
v-if="isTestAutomation"
|
||||
:automation-id="automation.id"
|
||||
:project-id="projectId"
|
||||
/>
|
||||
<ProjectPageAutomationFunctions
|
||||
v-else
|
||||
:automation="automation"
|
||||
:workspace-id="workspaceId"
|
||||
:project-id="projectId"
|
||||
:is-editable="isEditable"
|
||||
/>
|
||||
<ProjectPageAutomationModels :automation="automation" :project="project" />
|
||||
</div>
|
||||
<ProjectPageAutomationRuns
|
||||
class="col-span-1 xl:col-span-3"
|
||||
:project-id="projectId"
|
||||
:automation="automation"
|
||||
:is-editable="isEditable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CommonLoadingBar v-else-if="loading" loading />
|
||||
<CommonGenericEmptyState v-else message="Automation not found." />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { projectAutomationPageQuery } from '~/lib/projects/graphql/queries'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationPage_Automation on Automation {
|
||||
id
|
||||
permissions {
|
||||
canUpdate {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
...ProjectPageAutomationHeader_Automation
|
||||
...ProjectPageAutomationFunctions_Automation
|
||||
...ProjectPageAutomationRuns_Automation
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageAutomationPage_Project on Project {
|
||||
id
|
||||
workspaceId
|
||||
...ProjectPageAutomationHeader_Project
|
||||
}
|
||||
`)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const automationId = computed(() => route.params.aid as string)
|
||||
|
||||
const { result, loading } = useQuery(
|
||||
projectAutomationPageQuery,
|
||||
() => ({
|
||||
projectId: projectId.value,
|
||||
automationId: automationId.value
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
const automation = computed(() => result.value?.project.automation || null)
|
||||
const project = computed(() => result.value?.project)
|
||||
const workspaceId = computed(() => project.value?.workspaceId ?? undefined)
|
||||
const isEditable = computed(() => {
|
||||
return result?.value?.project?.automation?.permissions?.canUpdate.authorized ?? false
|
||||
})
|
||||
const isTestAutomation = computed(
|
||||
() => result.value?.project.automation.isTestAutomation
|
||||
)
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageAutomationsTab />
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageCollaborators />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageProjectFragment
|
||||
}
|
||||
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Collaborators | ${projectName.value}`
|
||||
})
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageDashboards />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageProjectFragment
|
||||
}
|
||||
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Dashboards | ${projectName.value}`
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageDiscussionsTab />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { ProjectPageProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageProjectFragment
|
||||
}
|
||||
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Discussions | ${projectName.value}`
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<ProjectPageModelsTab />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-3">
|
||||
<h1 class="block text-heading-lg mb-4 sm:mb-8">Settings</h1>
|
||||
<LayoutTabsVertical
|
||||
v-model:active-item="activeSettingsPageTab"
|
||||
:items="settingsTabItems"
|
||||
>
|
||||
<NuxtPage />
|
||||
</LayoutTabsVertical>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { LayoutTabsVertical, type LayoutPageTabItem } from '@speckle/ui-components'
|
||||
import {
|
||||
projectSettingsRoute,
|
||||
projectWebhooksRoute,
|
||||
projectTokensRoute,
|
||||
projectIntegrationsRoute
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { ProjectPageSettingsTab_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['can-view-settings']
|
||||
})
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageSettingsTab_Project on Project {
|
||||
id
|
||||
name
|
||||
permissions {
|
||||
canReadWebhooks {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canReadEmbedTokens {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canReadAccIntegrationSettings {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const attrs = useAttrs() as {
|
||||
project: ProjectPageSettingsTab_ProjectFragment
|
||||
}
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const canReadEmbedTokens = computed(() => attrs.project.permissions.canReadEmbedTokens)
|
||||
const canReadWebhooks = computed(() => attrs.project.permissions.canReadWebhooks)
|
||||
const projectName = computed(() =>
|
||||
attrs.project.name.length ? attrs.project.name : ''
|
||||
)
|
||||
const isAccEnabled = useIsAccModuleEnabled() // check permission over project
|
||||
const canReadAccIntegrationSettings = computed(
|
||||
() => attrs.project.permissions.canReadAccIntegrationSettings
|
||||
)
|
||||
|
||||
useHead({
|
||||
title: `Settings | ${projectName.value}`
|
||||
})
|
||||
|
||||
const settingsTabItems = computed((): LayoutPageTabItem[] => [
|
||||
{
|
||||
title: 'General',
|
||||
id: 'general'
|
||||
},
|
||||
{
|
||||
title: 'Webhooks',
|
||||
id: 'webhooks',
|
||||
disabled: !canReadWebhooks.value.authorized,
|
||||
disabledMessage: canReadWebhooks.value.message
|
||||
},
|
||||
{
|
||||
title: 'Tokens',
|
||||
id: 'tokens',
|
||||
disabled: !canReadEmbedTokens.value.authorized,
|
||||
disabledMessage: canReadEmbedTokens.value.message
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
id: 'integrations',
|
||||
disabled: isAccEnabled && !canReadAccIntegrationSettings.value.authorized,
|
||||
disabledMessage: canReadAccIntegrationSettings.value.message
|
||||
}
|
||||
])
|
||||
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
|
||||
const activeSettingsPageTab = computed({
|
||||
get: () => {
|
||||
const path = route.path
|
||||
if (path.includes('/settings/webhooks')) return settingsTabItems.value[1]
|
||||
if (path.includes('/settings/tokens')) return settingsTabItems.value[2]
|
||||
if (path.includes('/settings/integrations')) return settingsTabItems.value[3]
|
||||
return settingsTabItems.value[0]
|
||||
},
|
||||
set: (val: LayoutPageTabItem) => {
|
||||
switch (val.id) {
|
||||
case 'webhooks':
|
||||
router.push(projectWebhooksRoute(projectId.value))
|
||||
break
|
||||
case 'tokens':
|
||||
router.push(projectTokensRoute(projectId.value))
|
||||
break
|
||||
case 'integrations':
|
||||
router.push(projectIntegrationsRoute(projectId.value))
|
||||
break
|
||||
case 'general':
|
||||
default:
|
||||
router.push(projectSettingsRoute(projectId.value))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageSettingsGeneral />
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageSettingsAccTab :project-id="projectId" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
</script>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageSettingsTokens />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['can-view-project-tokens']
|
||||
})
|
||||
</script>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<ProjectPageSettingsWebhooks />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['can-view-webhooks']
|
||||
})
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<PresentationStateSetup>
|
||||
<PresentationPageWrapper />
|
||||
</PresentationStateSetup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'empty',
|
||||
middleware: ['require-valid-presentation']
|
||||
})
|
||||
|
||||
useHead({
|
||||
meta: [
|
||||
{
|
||||
name: 'viewport',
|
||||
content:
|
||||
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Utility route that allows us to generate comment links w/o having to fully
|
||||
* resolve them from viewerResources first (which is a heavy operation)
|
||||
*/
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'thread'
|
||||
})
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink :to="projectsRoute" name="Projects" :separator="false" />
|
||||
</Portal>
|
||||
<ProjectsDashboard v-if="isLoggedIn" />
|
||||
<div v-else class="mx-auto">
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { projectsRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
useHead({
|
||||
title: 'Projects'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'projects-active-check']
|
||||
})
|
||||
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'settings', 'admin'],
|
||||
layout: 'settings'
|
||||
})
|
||||
</script>
|
||||
@@ -1,214 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader title="General" text="Manage your server settings" />
|
||||
<div class="flex flex-col space-y-6">
|
||||
<SettingsSectionHeader title="Server details" subheading />
|
||||
<form class="flex flex-col gap-2" @submit="onSubmit">
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormTextInput
|
||||
v-model="name"
|
||||
label="Server public name"
|
||||
name="serverName"
|
||||
color="foundation"
|
||||
placeholder="Server name"
|
||||
show-label
|
||||
label-position="left"
|
||||
:rules="requiredRule"
|
||||
type="text"
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormTextArea
|
||||
v-model="description"
|
||||
color="foundation"
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Description"
|
||||
show-label
|
||||
label-position="left"
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormTextInput
|
||||
v-model="company"
|
||||
color="foundation"
|
||||
label="Owner"
|
||||
name="owner"
|
||||
placeholder="Owner"
|
||||
show-label
|
||||
label-position="left"
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormTextInput
|
||||
v-model="adminContact"
|
||||
color="foundation"
|
||||
label="Admin email"
|
||||
name="adminEmail"
|
||||
placeholder="Admin email"
|
||||
show-label
|
||||
type="email"
|
||||
label-position="left"
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormTextInput
|
||||
v-model="termsOfService"
|
||||
color="foundation"
|
||||
label="URL to the Terms of Service"
|
||||
name="terms"
|
||||
show-label
|
||||
label-position="left"
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormCheckbox
|
||||
v-model="inviteOnly"
|
||||
label="Invite only mode"
|
||||
description="Only users with an invitation will be able to join the server"
|
||||
label-position="left"
|
||||
name="inviteOnly"
|
||||
show-label
|
||||
/>
|
||||
<hr class="border-outline-3" />
|
||||
<FormCheckbox
|
||||
v-model="guestModeEnabled"
|
||||
label="Guest mode"
|
||||
description="Enables the 'Guest' server role, which allows users to only contribute to projects that they're invited to"
|
||||
label-position="left"
|
||||
name="guestModeEnabled"
|
||||
show-label
|
||||
/>
|
||||
<div class="mt-6">
|
||||
<FormButton color="primary" @click="onSubmit">Save changes</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<SettingsServerGeneralVersion />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery, useMutation } from '@vue/apollo-composable'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { isRequired } from '~~/lib/common/helpers/validation'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import { FormTextInput, useFormCheckboxModel } from '@speckle/ui-components'
|
||||
import { useLogger } from '~~/composables/logging'
|
||||
import {
|
||||
ROOT_QUERY,
|
||||
convertThrowIntoFetchResult,
|
||||
modifyObjectFields
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { serverInfoQuery } from '~~/lib/server-management/graphql/queries'
|
||||
import { serverInfoUpdateMutation } from '~~/lib/server-management/graphql/mutations'
|
||||
import type {
|
||||
ServerInfoUpdateMutationVariables,
|
||||
Query
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Server - General'
|
||||
})
|
||||
|
||||
type FormValues = {
|
||||
name: string
|
||||
description: string
|
||||
company: string
|
||||
adminContact: string
|
||||
termsOfService: string
|
||||
inviteOnly: boolean
|
||||
guestModeEnabled: boolean
|
||||
}
|
||||
|
||||
const logger = useLogger()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const { result } = useQuery(serverInfoQuery)
|
||||
const { mutate: updateServerInfo } = useMutation(serverInfoUpdateMutation)
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const company = ref('')
|
||||
const adminContact = ref('')
|
||||
const termsOfService = ref('')
|
||||
const { model: inviteOnly, isChecked: isInviteOnlyChecked } = useFormCheckboxModel()
|
||||
const { model: guestModeEnabled, isChecked: isGuestModeChecked } =
|
||||
useFormCheckboxModel()
|
||||
|
||||
const requiredRule = [isRequired]
|
||||
|
||||
const updateServerInfoAndCache = async (
|
||||
variables: ServerInfoUpdateMutationVariables
|
||||
) => {
|
||||
try {
|
||||
const result = await updateServerInfo(variables, {
|
||||
update: (cache, result) => {
|
||||
if (result?.data?.serverInfoUpdate) {
|
||||
// Modify 'serverInfo' field of ROOT_QUERY
|
||||
modifyObjectFields<undefined, Query['serverInfo']>(
|
||||
cache,
|
||||
ROOT_QUERY,
|
||||
(_fieldName, _variables, value) => {
|
||||
const newData = variables.info
|
||||
return {
|
||||
...value,
|
||||
...newData,
|
||||
guestModeEnabled: newData.guestModeEnabled ?? value.guestModeEnabled
|
||||
}
|
||||
},
|
||||
{ fieldNameWhitelist: ['serverInfo'] }
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
return convertThrowIntoFetchResult(error)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
const result = await updateServerInfoAndCache({
|
||||
info: {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
company: company.value,
|
||||
adminContact: adminContact.value,
|
||||
termsOfService: termsOfService.value,
|
||||
inviteOnly: isInviteOnlyChecked.value,
|
||||
guestModeEnabled: isGuestModeChecked.value
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Successfully saved',
|
||||
description: 'Your server settings have been saved.'
|
||||
})
|
||||
} else {
|
||||
logger.error(result && result.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Saving failed',
|
||||
description: 'Failed to update server info'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (!result.value?.serverInfo) return
|
||||
|
||||
name.value = result.value.serverInfo.name
|
||||
description.value = result.value.serverInfo.description || ''
|
||||
company.value = result.value.serverInfo.company || ''
|
||||
adminContact.value = result.value.serverInfo.adminContact || ''
|
||||
termsOfService.value = result.value.serverInfo.termsOfService || ''
|
||||
isInviteOnlyChecked.value = !!result.value.serverInfo.inviteOnly
|
||||
isGuestModeChecked.value = !!result.value.serverInfo.guestModeEnabled
|
||||
})
|
||||
</script>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Members"
|
||||
text="Manage members on your server"
|
||||
hide-divider
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<LayoutTabsHorizontal v-model:active-item="activeTab" :items="tabItems">
|
||||
<template #default="{ activeItem }">
|
||||
<SettingsServerActiveUsers v-if="activeItem.id === 'members'" />
|
||||
<SettingsServerPendingInvitations v-if="activeItem.id === 'invites'" />
|
||||
</template>
|
||||
</LayoutTabsHorizontal>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutPageTabItem } from '~~/lib/layout/helpers/components'
|
||||
import {
|
||||
getInvitesCountQuery,
|
||||
getUsersCountQuery
|
||||
} from '~~/lib/server-management/graphql/queries'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Server - Members'
|
||||
})
|
||||
|
||||
const { result: invitesResult } = useQuery(getInvitesCountQuery)
|
||||
const { result: usersResult } = useQuery(getUsersCountQuery)
|
||||
|
||||
const tabItems = computed<LayoutPageTabItem[]>(() => [
|
||||
{
|
||||
title: 'Members',
|
||||
id: 'members',
|
||||
count: usersResult.value?.admin?.userList?.totalCount
|
||||
},
|
||||
{
|
||||
title: 'Pending invites',
|
||||
id: 'invites',
|
||||
count: invitesResult.value?.admin?.inviteList?.totalCount
|
||||
}
|
||||
])
|
||||
|
||||
const activeTab = ref(tabItems.value[0])
|
||||
</script>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader title="Projects" text="Manage projects on your server" />
|
||||
<SettingsSharedProjects
|
||||
v-model:search="search"
|
||||
:projects="projects"
|
||||
:workspace-id="null"
|
||||
:workspace="null"
|
||||
/>
|
||||
<InfiniteLoading
|
||||
v-if="projects?.length"
|
||||
:settings="{ identifier }"
|
||||
class="py-4"
|
||||
@infinite="onInfiniteLoad"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { adminPanelProjectsQuery } from '~~/lib/server-management/graphql/queries'
|
||||
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsServerProjects_ProjectCollection on ProjectCollection {
|
||||
totalCount
|
||||
items {
|
||||
...SettingsSharedProjects_Project
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Server - Projects'
|
||||
})
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const {
|
||||
identifier,
|
||||
onInfiniteLoad,
|
||||
query: { result }
|
||||
} = usePaginatedQuery({
|
||||
query: adminPanelProjectsQuery,
|
||||
baseVariables: computed(() => ({
|
||||
query: search.value?.length ? search.value : null,
|
||||
limit: 50,
|
||||
cursor: null as Nullable<string>
|
||||
})),
|
||||
resolveKey: (vars) => [vars.query || ''],
|
||||
resolveCurrentResult: (res) => res?.admin.projectList,
|
||||
resolveNextPageVariables: (baseVars, cursor) => ({
|
||||
...baseVars,
|
||||
cursor
|
||||
}),
|
||||
resolveCursorFromVariables: (vars) => vars.cursor
|
||||
})
|
||||
|
||||
const projects = computed(() => result.value?.admin.projectList.items || [])
|
||||
</script>
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Regions"
|
||||
text="Manage the regions available for customizing data residency"
|
||||
/>
|
||||
<div class="flex flex-col space-y-6">
|
||||
<div class="flex flex-row-reverse">
|
||||
<div v-tippy="disabledMessage">
|
||||
<FormButton :disabled="isCreateDisabled" @click="onCreate">
|
||||
Create
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsServerRegionsTable :items="tableItems" @edit="onEditRegion" />
|
||||
</div>
|
||||
</div>
|
||||
<SettingsServerRegionsAddEditDialog
|
||||
v-model="editModel"
|
||||
v-model:open="isAddEditDialogOpen"
|
||||
:available-region-keys="availableKeys"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import type { SettingsServerRegionsTable_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Server - Regions'
|
||||
})
|
||||
|
||||
const isAddEditDialogOpen = ref(false)
|
||||
|
||||
const query = graphql(`
|
||||
query SettingsServerRegions {
|
||||
serverInfo {
|
||||
multiRegion {
|
||||
regions {
|
||||
id
|
||||
...SettingsServerRegionsTable_ServerRegionItem
|
||||
}
|
||||
availableKeys
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const editModel = ref<SettingsServerRegionsTable_ServerRegionItemFragment>()
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const { result } = useQuery(query, undefined, () => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
}))
|
||||
|
||||
const tableItems = computed(() => result.value?.serverInfo?.multiRegion?.regions)
|
||||
const availableKeys = computed(
|
||||
() => result.value?.serverInfo?.multiRegion?.availableKeys || []
|
||||
)
|
||||
|
||||
const canCreateRegion = computed(() => availableKeys.value.length > 0)
|
||||
const isCreateDisabled = computed(() => !canCreateRegion.value)
|
||||
|
||||
const disabledMessage = computed(() => {
|
||||
if (!isCreateDisabled.value) return undefined
|
||||
if (!availableKeys.value.length) return 'No available region keys'
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const onCreate = () => {
|
||||
editModel.value = undefined
|
||||
isAddEditDialogOpen.value = true
|
||||
}
|
||||
|
||||
const onEditRegion = (item: SettingsServerRegionsTable_ServerRegionItemFragment) => {
|
||||
editModel.value = item
|
||||
isAddEditDialogOpen.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'settings'],
|
||||
layout: 'settings'
|
||||
})
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<div class="flex flex-col">
|
||||
<SettingsSectionHeader
|
||||
title="Developer settings"
|
||||
text="Manage your tokens and authorized app"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col">
|
||||
<SettingsSectionHeader
|
||||
title="Explore GraphQL"
|
||||
class="md:gap-0"
|
||||
subheading
|
||||
:buttons="[
|
||||
{
|
||||
props: {
|
||||
color: 'outline',
|
||||
target: '_blank',
|
||||
external: true
|
||||
},
|
||||
onClick: goToExplorer,
|
||||
label: 'Open explorer'
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<hr class="border-outline-3" />
|
||||
<SettingsUserDeveloperAccessTokens @delete="openDeleteDialog" />
|
||||
<hr class="border-outline-3" />
|
||||
<SettingsUserDeveloperApplications @delete="openDeleteDialog" />
|
||||
<hr class="border-outline-3" />
|
||||
<SettingsUserDeveloperAuthorizedApps @delete="openDeleteDialog" />
|
||||
</div>
|
||||
</div>
|
||||
<SettingsUserDeveloperDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:item="itemToModify"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
TokenItem,
|
||||
ApplicationItem,
|
||||
AuthorizedAppItem
|
||||
} from '~~/lib/developer-settings/helpers/types'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings - Developer'
|
||||
})
|
||||
|
||||
const apiOrigin = useApiOrigin()
|
||||
|
||||
const itemToModify = ref<TokenItem | ApplicationItem | AuthorizedAppItem | null>(null)
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
const openDeleteDialog = (item: TokenItem | ApplicationItem | AuthorizedAppItem) => {
|
||||
itemToModify.value = item
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const goToExplorer = () => {
|
||||
if (!import.meta.client) return
|
||||
window.location.href = new URL('/explorer', apiOrigin).toString()
|
||||
}
|
||||
</script>
|
||||
@@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto">
|
||||
<SettingsSectionHeader
|
||||
title="Emails addresses"
|
||||
text="Manage your email addresses"
|
||||
/>
|
||||
<SettingsSectionHeader title="Your emails" subheading />
|
||||
<SettingsUserEmailList />
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<SettingsSectionHeader title="Add new email" subheading />
|
||||
<div class="flex flex-col md:flex-row w-full pt-4 md:pt-6 pb-6">
|
||||
<div class="flex flex-col md:flex-row gap-x-2 w-full">
|
||||
<FormTextInput
|
||||
v-model="email"
|
||||
color="foundation"
|
||||
label-position="left"
|
||||
label="Email address"
|
||||
name="email"
|
||||
:rules="[isEmail, isRequired]"
|
||||
placeholder="Email address"
|
||||
show-label
|
||||
wrapper-classes="flex-1 py-3 md:py-0 w-full"
|
||||
/>
|
||||
<FormButton @click="onAddEmailSubmit">Add</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import { isEmail, isRequired } from '~~/lib/common/helpers/validation'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings - Emails'
|
||||
})
|
||||
|
||||
type FormValues = { email: string }
|
||||
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const { addUserEmail } = useUserEmails()
|
||||
const email = ref('')
|
||||
|
||||
const onAddEmailSubmit = handleSubmit(async () => {
|
||||
const success = await addUserEmail(email.value)
|
||||
if (success) {
|
||||
email.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Notifications"
|
||||
text="Your notification preferences"
|
||||
/>
|
||||
<table class="table-auto w-full rounded-t overflow-hidden">
|
||||
<thead class="text-foreground-1">
|
||||
<tr>
|
||||
<th class="pb-4 font-medium text-sm text-left">Notification type</th>
|
||||
<th
|
||||
v-for="channel in notificationChannels"
|
||||
:key="channel"
|
||||
class="text-right font-medium pb-4 text-sm"
|
||||
>
|
||||
{{ capitalize(channel) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="[type, settings] in Object.entries(localPreferences)"
|
||||
:key="type"
|
||||
class="border-t border-outline-3"
|
||||
>
|
||||
<td class="text-body-xs py-4">
|
||||
{{ notificationTypeMapping[type] || 'Unknown' }}
|
||||
</td>
|
||||
<td
|
||||
v-for="channel in notificationChannels"
|
||||
:key="channel"
|
||||
class="flex justify-end py-4"
|
||||
>
|
||||
<FormCheckbox
|
||||
:name="`${type} (${channel})`"
|
||||
:disabled="loading"
|
||||
hide-label
|
||||
:model-value="settings[channel] || undefined"
|
||||
@update:model-value="
|
||||
($event) => onUpdate({ value: !!$event, type, channel })
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { capitalize, cloneDeep } from 'lodash-es'
|
||||
import { useUpdateNotificationPreferences } from '~~/lib/user/composables/management'
|
||||
import type { NotificationPreferences } from '~~/lib/user/helpers/components'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings - Notifications'
|
||||
})
|
||||
|
||||
const { mutate, loading } = useUpdateNotificationPreferences()
|
||||
const { activeUser: user } = useActiveUser()
|
||||
|
||||
const notificationTypeMapping = ref({
|
||||
activityDigest: 'Weekly activity digest',
|
||||
mentionedInComment: 'Mentioned in comment',
|
||||
newStreamAccessRequest: 'Project access request',
|
||||
streamAccessRequestApproved: 'Project access request approved'
|
||||
} as Record<string, string>)
|
||||
|
||||
const localPreferences = ref({} as NotificationPreferences)
|
||||
|
||||
const notificationPreferences = computed(
|
||||
() => user.value?.notificationPreferences || ({} as NotificationPreferences)
|
||||
)
|
||||
const notificationChannels = computed(() => {
|
||||
const firstTypeSettings = Object.values(notificationPreferences.value)[0] || {}
|
||||
return Object.keys(firstTypeSettings)
|
||||
})
|
||||
|
||||
const onUpdate = async (params: { value: boolean; channel: string; type: string }) => {
|
||||
const { value, channel, type } = params
|
||||
localPreferences.value[type][channel] = value
|
||||
await mutate(localPreferences.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
notificationPreferences,
|
||||
(prefs) => {
|
||||
localPreferences.value = cloneDeep(prefs)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="user">
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader title="Profile" text="Manage your profile" />
|
||||
<SettingsUserProfileDetails :user="user" />
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<SettingsUserProfileChangePassword :user="user" />
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<SettingsUserProfileDeleteAccount :user="user" />
|
||||
<hr class="my-6 md:my-8 border-outline-2" />
|
||||
<div class="text-body-2xs text-foreground-2 w-full flex flex-col space-y-2">
|
||||
<div class="flex">
|
||||
User ID: #{{ user.id }}
|
||||
<ClipboardIcon
|
||||
class="w-4 h-4 ml-2 cursor-pointer hover:text-foreground transition"
|
||||
@click="copyUserId"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="distinctId" class="flex">
|
||||
{{ distinctId }}
|
||||
<ClipboardIcon
|
||||
class="w-4 h-4 ml-2 cursor-pointer hover:text-foreground transition"
|
||||
@click="copyDistinctId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useActiveUser } from '~/lib/auth/composables/activeUser'
|
||||
import { ClipboardIcon } from '@heroicons/vue/24/outline'
|
||||
|
||||
useHead({
|
||||
title: 'Settings - Profile'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
const { distinctId, activeUser: user } = useActiveUser()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const copyUserId = () => {
|
||||
if (user.value) {
|
||||
copy(user.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
const copyDistinctId = () => {
|
||||
if (distinctId.value) {
|
||||
copy(distinctId.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
'auth',
|
||||
'settings',
|
||||
'requires-workspaces-enabled',
|
||||
'require-valid-workspace'
|
||||
],
|
||||
layout: 'settings'
|
||||
})
|
||||
</script>
|
||||
@@ -1,59 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Automation"
|
||||
text="Manage workspace functions and project automations"
|
||||
/>
|
||||
<SettingsWorkspacesAutomationFunctions
|
||||
:workspace-functions="workspaceFunctions"
|
||||
/>
|
||||
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { settingsWorkspacesAutomationQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Automation'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
const isAutomateEnabled = useIsAutomateModuleEnabled()
|
||||
|
||||
const {
|
||||
identifier,
|
||||
onInfiniteLoad,
|
||||
query: { result }
|
||||
} = usePaginatedQuery({
|
||||
query: settingsWorkspacesAutomationQuery,
|
||||
baseVariables: computed(() => ({
|
||||
slug: slug.value,
|
||||
cursor: null as Nullable<string>
|
||||
})),
|
||||
options: () => ({
|
||||
enabled: isAutomateEnabled.value
|
||||
}),
|
||||
resolveCurrentResult: (res) => res?.workspaceBySlug?.automateFunctions,
|
||||
resolveInitialResult: () => ({
|
||||
items: [],
|
||||
cursor: undefined
|
||||
}),
|
||||
resolveNextPageVariables: (baseVars, cursor) => ({ ...baseVars, cursor }),
|
||||
resolveKey: (vars) => [vars.slug],
|
||||
resolveCursorFromVariables: (vars) => vars.cursor
|
||||
})
|
||||
|
||||
const workspaceFunctions = computed(
|
||||
() => result?.value?.workspaceBySlug.automateFunctions.items ?? []
|
||||
)
|
||||
</script>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<SettingsWorkspacesBillingPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Billing'
|
||||
})
|
||||
</script>
|
||||
@@ -1,447 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader title="General" text="Manage your workspace settings" />
|
||||
<SettingsSectionHeader title="Workspace details" subheading />
|
||||
<div class="pt-6">
|
||||
<FormTextInput
|
||||
v-model="name"
|
||||
color="foundation"
|
||||
label="Name"
|
||||
name="name"
|
||||
placeholder="Workspace name"
|
||||
show-label
|
||||
:disabled="!isAdmin || needsSsoLogin"
|
||||
:tooltip-text="disabledTooltipText"
|
||||
label-position="left"
|
||||
:rules="[isRequired, isStringOfLength({ maxLength: 512 })]"
|
||||
validate-on-value-update
|
||||
@change="save()"
|
||||
/>
|
||||
<ClientOnly>
|
||||
<hr class="my-4 border-outline-3" />
|
||||
<FormTextInput
|
||||
id="short-id"
|
||||
v-model="slug"
|
||||
color="foundation"
|
||||
label="Short ID"
|
||||
name="shortId"
|
||||
:help="slugHelp"
|
||||
:disabled="disableSlugInput"
|
||||
show-label
|
||||
label-position="left"
|
||||
:tooltip-text="disabledSlugTooltipText"
|
||||
read-only
|
||||
:right-icon="disableSlugInput ? undefined : IconEdit"
|
||||
:right-icon-title="disableSlugInput ? undefined : 'Edit short ID'"
|
||||
custom-help-class="!break-all"
|
||||
@right-icon-click="openSlugEditDialog"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<hr class="my-4 border-outline-3" />
|
||||
<FormTextArea
|
||||
id="settings-description"
|
||||
v-model="description"
|
||||
color="foundation"
|
||||
label="Description"
|
||||
name="description"
|
||||
placeholder="Workspace description"
|
||||
:tooltip-text="disabledTooltipText"
|
||||
show-label
|
||||
label-position="left"
|
||||
:disabled="!isAdmin || needsSsoLogin"
|
||||
:rules="[isStringOfLength({ maxLength: 512 })]"
|
||||
help="Maximum 512 characters"
|
||||
@change="save()"
|
||||
/>
|
||||
<hr class="my-4 border-outline-3" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-body-xs font-medium text-foreground">Workspace icon</span>
|
||||
<span class="text-body-2xs text-foreground-2 max-w-[230px]">
|
||||
Upload your icon image
|
||||
</span>
|
||||
</div>
|
||||
<div :key="String(isAdmin)" v-tippy="disabledTooltipText">
|
||||
<SettingsWorkspacesGeneralEditAvatar
|
||||
v-if="workspaceResult?.workspaceBySlug"
|
||||
:workspace="workspaceResult?.workspaceBySlug"
|
||||
:disabled="!isAdmin || needsSsoLogin"
|
||||
size="3xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4 border-outline-3" />
|
||||
<div class="grid grid-cols-2 gap-4 pt-1">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-body-xs font-medium text-foreground">
|
||||
Speckle logo in embeds
|
||||
</span>
|
||||
<span class="text-body-2xs text-foreground-2 max-w-[230px]">
|
||||
Control the visibility of the Speckle logo in model embeds
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex h-full flex-col justify-center gap-y-2">
|
||||
<ClientOnly>
|
||||
<div
|
||||
v-tippy="
|
||||
!canEditEmbedOptions?.authorized
|
||||
? canEditEmbedOptions?.message
|
||||
: undefined
|
||||
"
|
||||
class="flex items-center gap-x-2"
|
||||
>
|
||||
<FormSwitch
|
||||
v-model="showBranding"
|
||||
:disabled="!canEditEmbedOptions?.authorized || needsSsoLogin"
|
||||
name="showBranding"
|
||||
label="Show branding"
|
||||
:show-label="false"
|
||||
@update:model-value="updateShowBranding"
|
||||
/>
|
||||
<p class="text-body-xs text-foreground-2">
|
||||
{{ showBranding ? 'Logo visible' : 'Logo hidden' }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="
|
||||
!canEditEmbedOptions?.authorized &&
|
||||
canEditEmbedOptions?.code === 'WorkspaceNoFeatureAccess'
|
||||
"
|
||||
class="text-body-2xs text-foreground-2"
|
||||
>
|
||||
This feature is only available on the business plan
|
||||
<NuxtLink
|
||||
:to="settingsWorkspaceRoutes.billing.route(slug)"
|
||||
class="underline"
|
||||
>
|
||||
upgrade now
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-6 border-outline-2" />
|
||||
<div class="flex flex-col space-y-6">
|
||||
<SettingsSectionHeader title="Leave workspace" subheading />
|
||||
<CommonCard class="text-body-xs bg-foundation">
|
||||
By clicking the button below you will leave this workspace.
|
||||
</CommonCard>
|
||||
<div>
|
||||
<FormButton color="primary" @click="showLeaveDialog = true">
|
||||
Leave workspace
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="isAdmin">
|
||||
<hr class="mb-6 mt-8 border-outline-2" />
|
||||
<div class="flex flex-col space-y-6">
|
||||
<SettingsSectionHeader title="Delete workspace" subheading />
|
||||
<CommonCard class="text-body-xs bg-foundation">
|
||||
We will delete all projects where you are the sole owner, and any associated
|
||||
data. We will ask you to type in your email address and press the delete
|
||||
button.
|
||||
</CommonCard>
|
||||
|
||||
<div class="flex">
|
||||
<div v-tippy="deleteWorkspaceTooltip">
|
||||
<FormButton
|
||||
:disabled="!canDeleteWorkspace"
|
||||
color="primary"
|
||||
@click="showDeleteDialog = true"
|
||||
>
|
||||
Delete workspace
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="workspaceResult?.workspaceBySlug?.id">
|
||||
<hr class="mb-6 mt-8 border-outline-2" />
|
||||
<p class="text-body-2xs text-foreground-2">
|
||||
Workspace ID: #{{ workspaceResult?.workspaceBySlug?.id }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<SettingsWorkspacesGeneralLeaveDialog
|
||||
v-model:open="showLeaveDialog"
|
||||
:workspace="workspaceResult?.workspaceBySlug"
|
||||
/>
|
||||
|
||||
<SettingsWorkspacesGeneralDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:workspace="workspaceResult?.workspaceBySlug"
|
||||
/>
|
||||
|
||||
<SettingsWorkspacesGeneralEditSlugDialog
|
||||
v-model:open="showEditSlugDialog"
|
||||
:base-url="baseUrl"
|
||||
:workspace="workspaceResult?.workspaceBySlug"
|
||||
@update:slug="updateWorkspaceSlug"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useQuery, useMutation } from '@vue/apollo-composable'
|
||||
import {
|
||||
settingsUpdateWorkspaceMutation,
|
||||
settingsUpdateWorkspaceEmbedOptionsMutation
|
||||
} from '~/lib/settings/graphql/mutations'
|
||||
import { settingsWorkspaceGeneralQuery } from '~/lib/settings/graphql/queries'
|
||||
import type { WorkspaceUpdateInput } from '~~/lib/common/generated/gql/graphql'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import {
|
||||
getFirstErrorMessage,
|
||||
convertThrowIntoFetchResult
|
||||
} from '~~/lib/common/helpers/graphql'
|
||||
import { isRequired, isStringOfLength } from '~~/lib/common/helpers/validation'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { Roles, WorkspacePlans } from '@speckle/shared'
|
||||
import { workspaceRoute, settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { WorkspacePlanStatuses } from '~/lib/common/generated/gql/graphql'
|
||||
import { useWorkspaceSsoStatus } from '~/lib/workspaces/composables/sso'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesGeneral_Workspace on Workspace {
|
||||
...SettingsWorkspacesGeneralEditAvatar_Workspace
|
||||
...SettingsWorkspaceGeneralDeleteDialog_Workspace
|
||||
...SettingsWorkspacesGeneralEditSlugDialog_Workspace
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
logo
|
||||
role
|
||||
plan {
|
||||
status
|
||||
name
|
||||
}
|
||||
embedOptions {
|
||||
hideSpeckleBranding
|
||||
}
|
||||
permissions {
|
||||
canEditEmbedOptions {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - General'
|
||||
})
|
||||
|
||||
type FormValues = { name: string; description: string }
|
||||
|
||||
const routeSlug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const IconEdit = resolveComponent('IconEdit')
|
||||
|
||||
const isBillingIntegrationEnabled = useIsBillingIntegrationEnabled()
|
||||
const mixpanel = useMixpanel()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { mutate: updateMutation } = useMutation(settingsUpdateWorkspaceMutation)
|
||||
const { mutate: updateEmbedOptionsMutation } = useMutation(
|
||||
settingsUpdateWorkspaceEmbedOptionsMutation
|
||||
)
|
||||
const { result: workspaceResult } = useQuery(settingsWorkspaceGeneralQuery, () => ({
|
||||
slug: routeSlug.value
|
||||
}))
|
||||
const config = useRuntimeConfig()
|
||||
const { hasSsoEnabled, needsSsoLogin } = useWorkspaceSsoStatus({
|
||||
workspaceSlug: computed(() => workspaceResult.value?.workspaceBySlug?.slug || '')
|
||||
})
|
||||
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const description = ref('')
|
||||
const showDeleteDialog = ref(false)
|
||||
const showEditSlugDialog = ref(false)
|
||||
const showLeaveDialog = ref(false)
|
||||
const showBranding = ref(true)
|
||||
|
||||
const isAdmin = computed(
|
||||
() => workspaceResult.value?.workspaceBySlug?.role === Roles.Workspace.Admin
|
||||
)
|
||||
const adminRef = toRef(isAdmin)
|
||||
const canDeleteWorkspace = computed(
|
||||
() =>
|
||||
isAdmin.value &&
|
||||
!needsSsoLogin.value &&
|
||||
(!isBillingIntegrationEnabled ||
|
||||
!(
|
||||
[
|
||||
WorkspacePlanStatuses.Valid,
|
||||
WorkspacePlanStatuses.PaymentFailed,
|
||||
WorkspacePlanStatuses.CancelationScheduled
|
||||
] as string[]
|
||||
).includes(
|
||||
workspaceResult.value?.workspaceBySlug?.plan?.status as WorkspacePlanStatuses
|
||||
) ||
|
||||
workspaceResult.value?.workspaceBySlug?.plan?.name === WorkspacePlans.Free)
|
||||
)
|
||||
const deleteWorkspaceTooltip = computed(() => {
|
||||
if (needsSsoLogin.value)
|
||||
return 'You cannot delete a workspace that requires SSO without an active session'
|
||||
if (!canDeleteWorkspace.value)
|
||||
return 'You cannot delete a workspace with an active plan. Please cancel your plan before deleting.'
|
||||
if (!isAdmin.value) return 'Only admins can delete workspaces'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const save = handleSubmit(async () => {
|
||||
if (!workspaceResult.value?.workspaceBySlug) return
|
||||
|
||||
const input: WorkspaceUpdateInput = {
|
||||
id: workspaceResult.value.workspaceBySlug.id
|
||||
}
|
||||
if (name.value !== workspaceResult.value.workspaceBySlug.name) input.name = name.value
|
||||
if (description.value !== workspaceResult.value.workspaceBySlug.description)
|
||||
input.description = description.value
|
||||
|
||||
const result = await updateMutation({ input }).catch(convertThrowIntoFetchResult)
|
||||
|
||||
if (result?.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Workspace updated'
|
||||
})
|
||||
|
||||
mixpanel.track('Workspace General Settings Updated', {
|
||||
fields: (Object.keys(input) as Array<keyof WorkspaceUpdateInput>).filter(
|
||||
(key) => key !== 'id'
|
||||
),
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceResult.value.workspaceBySlug.id,
|
||||
source: 'settings'
|
||||
})
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result?.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Workspace update failed',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workspaceResult,
|
||||
() => {
|
||||
if (workspaceResult.value?.workspaceBySlug) {
|
||||
name.value = workspaceResult.value.workspaceBySlug.name
|
||||
description.value = workspaceResult.value.workspaceBySlug.description ?? ''
|
||||
slug.value = workspaceResult.value.workspaceBySlug.slug ?? ''
|
||||
showBranding.value =
|
||||
!workspaceResult.value.workspaceBySlug.embedOptions.hideSpeckleBranding
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const baseUrl = config.public.baseUrl
|
||||
|
||||
const slugHelp = computed(() => {
|
||||
// Ensure the correct slug is used both on the server and client
|
||||
if (!workspaceResult.value?.workspaceBySlug) {
|
||||
return `${baseUrl}/workspaces/${routeSlug.value}`
|
||||
}
|
||||
return `${baseUrl}/workspaces/${workspaceResult.value.workspaceBySlug.slug}`
|
||||
})
|
||||
|
||||
const canEditEmbedOptions = computed(
|
||||
() => workspaceResult.value?.workspaceBySlug?.permissions?.canEditEmbedOptions
|
||||
)
|
||||
|
||||
const disabledTooltipText = computed(() => {
|
||||
if (!adminRef.value) return 'Only admins can edit this field'
|
||||
if (needsSsoLogin.value) return 'Log in with your SSO provider to edit this field'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const disableSlugInput = computed(() => !isAdmin.value || hasSsoEnabled.value)
|
||||
|
||||
const disabledSlugTooltipText = computed(() => {
|
||||
return hasSsoEnabled.value
|
||||
? 'Short ID cannot be changed while SSO is enabled.'
|
||||
: disabledTooltipText.value
|
||||
})
|
||||
|
||||
const openSlugEditDialog = () => {
|
||||
if (hasSsoEnabled.value) return
|
||||
showEditSlugDialog.value = true
|
||||
}
|
||||
|
||||
const updateShowBranding = async () => {
|
||||
if (!workspaceResult.value?.workspaceBySlug) return
|
||||
|
||||
const result = await updateEmbedOptionsMutation({
|
||||
input: {
|
||||
workspaceId: workspaceResult.value.workspaceBySlug.id,
|
||||
hideSpeckleBranding: !showBranding.value
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.data) {
|
||||
mixpanel.track('Workspace Embed Options Updated', {
|
||||
hideBranding: !showBranding.value,
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspaceResult.value.workspaceBySlug.id
|
||||
})
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: `Speckle logo on embeds ${showBranding.value ? 'enabled' : 'disabled'}`
|
||||
})
|
||||
}
|
||||
}
|
||||
const updateWorkspaceSlug = async (newSlug: string) => {
|
||||
if (!workspaceResult.value?.workspaceBySlug) {
|
||||
return
|
||||
}
|
||||
|
||||
const oldSlug = slug.value
|
||||
|
||||
const result = await updateMutation({
|
||||
input: {
|
||||
id: workspaceResult.value.workspaceBySlug.id,
|
||||
slug: newSlug
|
||||
}
|
||||
})
|
||||
|
||||
if (result && result.data) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Workspace short ID updated'
|
||||
})
|
||||
|
||||
showEditSlugDialog.value = false
|
||||
|
||||
slug.value = newSlug
|
||||
|
||||
if (routeSlug.value === oldSlug) {
|
||||
router.replace(workspaceRoute(newSlug))
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFirstErrorMessage(result && result.errors)
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: 'Failed to update workspace slug',
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<SettingsSectionHeader
|
||||
title="Integrations"
|
||||
text="Connect your workspace to authorized applications."
|
||||
/>
|
||||
<IntegrationsAccCard
|
||||
:workspace-id="workspaceResult?.workspaceBySlug.id || ''"
|
||||
:workspace-slug="routeSlug"
|
||||
></IntegrationsAccCard>
|
||||
<!-- <div v-for="integration in integrations" :key="integration.cookieKey">
|
||||
<IntegrationsCard
|
||||
:integration="integration"
|
||||
@handle-c-t-a="handleCTA(integration)"
|
||||
></IntegrationsCard>
|
||||
</div> -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { settingsWorkspaceGeneralQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const routeSlug = computed(() => (route.params.slug as string) || '')
|
||||
const { result: workspaceResult } = useQuery(settingsWorkspaceGeneralQuery, () => ({
|
||||
slug: routeSlug.value
|
||||
}))
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-16">
|
||||
<SettingsSectionHeader
|
||||
hide-divider
|
||||
title="People"
|
||||
text="Manage users in your workspace"
|
||||
class="mb-6"
|
||||
/>
|
||||
<LayoutTabsHorizontal v-model:active-item="activeTab" :items="tabItems">
|
||||
<NuxtPage />
|
||||
</LayoutTabsHorizontal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { settingsWorkspacesMembersQuery } from '~/lib/settings/graphql/queries'
|
||||
import type { LayoutPageTabItem } from '~~/lib/layout/helpers/components'
|
||||
import { useOnWorkspaceUpdated } from '~/lib/workspaces/composables/management'
|
||||
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
|
||||
import { WorkspaceJoinRequestStatus } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesMembersCounts_Workspace on Workspace {
|
||||
id
|
||||
role
|
||||
invitedTeam {
|
||||
id
|
||||
}
|
||||
teamByRole {
|
||||
admins {
|
||||
totalCount
|
||||
}
|
||||
members {
|
||||
totalCount
|
||||
}
|
||||
guests {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
adminWorkspacesJoinRequests(filter: $filter) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Members'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const { result } = useQuery(settingsWorkspacesMembersQuery, () => ({
|
||||
slug: slug.value,
|
||||
filter: {
|
||||
status: WorkspaceJoinRequestStatus.Pending
|
||||
}
|
||||
}))
|
||||
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
const isAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
|
||||
|
||||
const memberTotalCount = computed(
|
||||
() =>
|
||||
(workspace.value?.teamByRole.members?.totalCount ?? 0) +
|
||||
(workspace.value?.teamByRole.admins?.totalCount ?? 0)
|
||||
)
|
||||
const guestTotalCount = computed(
|
||||
() => workspace.value?.teamByRole.guests?.totalCount ?? 0
|
||||
)
|
||||
const invitedCount = computed(() => workspace.value?.invitedTeam?.length)
|
||||
const joinRequestCount = computed(
|
||||
() => workspace.value?.adminWorkspacesJoinRequests?.totalCount
|
||||
)
|
||||
|
||||
const tabItems = computed<LayoutPageTabItem[]>(() => [
|
||||
{ title: 'Members', id: 'members', count: memberTotalCount.value },
|
||||
{ title: 'Guests', id: 'guests', count: guestTotalCount.value },
|
||||
{
|
||||
title: 'Pending invites',
|
||||
id: 'invites',
|
||||
disabled: !isAdmin.value,
|
||||
disabledMessage: 'Only workspace admins can manage invites',
|
||||
count: invitedCount.value
|
||||
},
|
||||
{
|
||||
title: 'Join requests',
|
||||
id: 'joinRequests',
|
||||
disabled: !isAdmin.value,
|
||||
disabledMessage: 'Only workspace admins can manage join requests',
|
||||
count: joinRequestCount.value
|
||||
}
|
||||
])
|
||||
|
||||
const activeTab = computed({
|
||||
get: () => {
|
||||
const path = route.path
|
||||
if (path.includes('/members/guests')) return tabItems.value[1]
|
||||
if (path.includes('/members/invites')) return tabItems.value[2]
|
||||
if (path.includes('/members/requests')) return tabItems.value[3]
|
||||
if (path.includes('/members')) return tabItems.value[0]
|
||||
return tabItems.value[0]
|
||||
},
|
||||
set: (val: LayoutPageTabItem) => {
|
||||
switch (val.id) {
|
||||
case 'members':
|
||||
router.push(settingsWorkspaceRoutes.members.route(slug.value))
|
||||
break
|
||||
case 'guests':
|
||||
router.push(settingsWorkspaceRoutes.membersGuests.route(slug.value))
|
||||
break
|
||||
case 'invites':
|
||||
router.push(settingsWorkspaceRoutes.membersInvites.route(slug.value))
|
||||
break
|
||||
case 'joinRequests':
|
||||
router.push(settingsWorkspaceRoutes.membersRequests.route(slug.value))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useOnWorkspaceUpdated({ workspaceSlug: slug })
|
||||
</script>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<SettingsWorkspacesMembersGuestsTable :workspace-slug="slug" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
</script>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<SettingsWorkspacesMembersTable :workspace-slug="slug" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<SettingsWorkspacesMembersInvitesTable
|
||||
:workspace="workspace"
|
||||
:workspace-slug="slug"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { settingsWorkspacesMembersInvitesQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const { result } = useQuery(settingsWorkspacesMembersInvitesQuery, () => ({
|
||||
slug: slug.value
|
||||
}))
|
||||
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<SettingsWorkspacesMembersJoinRequestsTable
|
||||
:workspace="workspace"
|
||||
:workspace-slug="slug"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { settingsWorkspacesMembersRequestsQuery } from '~/lib/settings/graphql/queries'
|
||||
|
||||
const route = useRoute()
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const { result } = useQuery(settingsWorkspacesMembersRequestsQuery, () => ({
|
||||
slug: slug.value
|
||||
}))
|
||||
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
</script>
|
||||
@@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Projects"
|
||||
text="Manage projects in your workspace"
|
||||
/>
|
||||
<div v-if="loading && !projects.length" class="flex justify-center py-8">
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
<SettingsSharedProjects
|
||||
v-else
|
||||
v-model:search="search"
|
||||
:projects="projects"
|
||||
:workspace-id="result?.workspaceBySlug.id"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
<InfiniteLoading
|
||||
v-if="projects?.length"
|
||||
:settings="{ identifier }"
|
||||
class="py-4"
|
||||
@infinite="onInfiniteLoad"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { settingsWorkspacesProjectsQuery } from '~~/lib/settings/graphql/queries'
|
||||
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { useWorkspaceProjectsUpdatedTracking } from '~/lib/workspaces/composables/projectUpdates'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesProjects_ProjectCollection on ProjectCollection {
|
||||
totalCount
|
||||
items {
|
||||
...SettingsSharedProjects_Project
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesProjects_Workspace on Workspace {
|
||||
id
|
||||
name
|
||||
slug
|
||||
plan {
|
||||
name
|
||||
}
|
||||
role
|
||||
permissions {
|
||||
canCreateProject {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Projects'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const search = ref('')
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const {
|
||||
identifier,
|
||||
onInfiniteLoad,
|
||||
query: { result, loading }
|
||||
} = usePaginatedQuery({
|
||||
query: settingsWorkspacesProjectsQuery,
|
||||
baseVariables: computed(() => ({
|
||||
limit: 50,
|
||||
filter: { search: search.value?.length ? search.value : null },
|
||||
slug: slug.value,
|
||||
cursor: null as Nullable<string>
|
||||
})),
|
||||
resolveKey: (vars) => [vars.slug, vars.filter?.search || ''],
|
||||
resolveCurrentResult: (res) => res?.workspaceBySlug.projects,
|
||||
resolveNextPageVariables: (baseVars, cursor) => ({
|
||||
...baseVars,
|
||||
cursor
|
||||
}),
|
||||
resolveCursorFromVariables: (vars) => vars.cursor
|
||||
})
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
const projects = computed(() => result.value?.workspaceBySlug.projects.items || [])
|
||||
|
||||
useWorkspaceProjectsUpdatedTracking(computed(() => slug.value))
|
||||
</script>
|
||||
@@ -1,234 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Data residency"
|
||||
text="Manage where your workspace data resides."
|
||||
/>
|
||||
<CommonLoadingIcon
|
||||
v-if="isQueryLoading || !workspace"
|
||||
class="justify-self-center"
|
||||
/>
|
||||
<template v-else-if="workspace.hasAccessToMultiRegion">
|
||||
<template v-if="!workspace.defaultRegion">
|
||||
<div class="p-4 bg-foundation border-outline-2 border text-body-xs">
|
||||
The default region is the geographical boundary where your workspace's
|
||||
project data resides. Changing the default region means pinning your data to
|
||||
a new location. This change will affect only new projects created within
|
||||
your workspace.
|
||||
</div>
|
||||
<div class="pt-14 flex flex-col space-y-4">
|
||||
<SettingsWorkspacesRegionsSelect
|
||||
v-model="defaultRegion"
|
||||
show-label
|
||||
label="Default region"
|
||||
:items="availableRegions || []"
|
||||
:disabled="
|
||||
!availableRegions?.length || isMutationLoading || !isWorkspaceAdmin
|
||||
"
|
||||
label-position="left"
|
||||
/>
|
||||
<div class="w-full flex justify-end">
|
||||
<FormButton
|
||||
:disabled="!isWorkspaceAdmin || isMutationLoading || !defaultRegion"
|
||||
@click="onDefaultRegionSave"
|
||||
>
|
||||
Save
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<div class="text-heading-lg">Current data region</div>
|
||||
<div class="px-6 py-4 border border-outline-3 rounded-lg flex flex-col">
|
||||
<div class="text-foreground text-body-xs font-semibold">
|
||||
{{ workspace.defaultRegion.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="workspace.defaultRegion.description"
|
||||
class="text-foreground-2 text-body-2xs font-normal"
|
||||
>
|
||||
{{ workspace.defaultRegion.description }}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-outline-2" />
|
||||
<div class="text-heading-lg">Change data region</div>
|
||||
<div
|
||||
class="p-4 border border-outline-3 rounded-lg flex gap-3 flex-col md:flex-row md:items-center"
|
||||
>
|
||||
<div class="text-body-xs font-normal">
|
||||
Change your default data region and schedule a data residency move.
|
||||
</div>
|
||||
<span v-tippy="'Coming soon'" class="basis-full md:basis-auto">
|
||||
<FormButton color="outline" disabled full-width>
|
||||
Change data region
|
||||
</FormButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex gap-2 flex-col md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-heading-sm text-foreground">Enable Data Residency</div>
|
||||
<div class="text-body-2xs text-foreground-2">
|
||||
Control where your workspace data is hosted.
|
||||
</div>
|
||||
</div>
|
||||
<FormButton
|
||||
class="!max-w-none !md:max-w-max w-full md:w-auto"
|
||||
:to="settingsWorkspaceRoutes.billing.route(slug)"
|
||||
>
|
||||
Upgrade to Business
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<LayoutDialog
|
||||
v-if="defaultRegion"
|
||||
v-model:open="showDefaultRegionSaveDisclaimer"
|
||||
max-width="sm"
|
||||
title="Confirm region change"
|
||||
:buttons="saveDisclaimerButtons"
|
||||
>
|
||||
<!-- prettier-ignore -->
|
||||
<span>
|
||||
Confirm that you want to update your workspace's region to
|
||||
<span class="font-semibold">{{ defaultRegion.name }}</span>.
|
||||
This cannot be undone.
|
||||
</span>
|
||||
<template v-if="hasProjects">
|
||||
<br />
|
||||
<br />
|
||||
<CommonAlert color="warning">
|
||||
<template #description>
|
||||
Please note that existing projects in your workspace will not be moved to
|
||||
the new region as we currently do not support moving projects between
|
||||
regions. However, this will be supported soon and we will make sure to move
|
||||
over your projects.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</template>
|
||||
</LayoutDialog>
|
||||
</section>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useMutationLoading, useQuery, useQueryLoading } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { SettingsWorkspacesRegionsSelect_ServerRegionItemFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { settingsWorkspaceRegionsQuery } from '~/lib/settings/graphql/queries'
|
||||
import { useSetDefaultWorkspaceRegion } from '~/lib/workspaces/composables/management'
|
||||
import { settingsWorkspaceRoutes } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesRegions_Workspace on Workspace {
|
||||
id
|
||||
role
|
||||
defaultRegion {
|
||||
id
|
||||
...SettingsWorkspacesRegionsSelect_ServerRegionItem
|
||||
}
|
||||
hasAccessToMultiRegion: hasAccessToFeature(
|
||||
featureName: workspaceDataRegionSpecificity
|
||||
)
|
||||
hasProjects: projects(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesRegions_ServerInfo on ServerInfo {
|
||||
multiRegion {
|
||||
regions {
|
||||
id
|
||||
...SettingsWorkspacesRegionsSelect_ServerRegionItem
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Regions'
|
||||
})
|
||||
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
|
||||
const route = useRoute()
|
||||
const mp = useMixpanel()
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const isMutationLoading = useMutationLoading()
|
||||
const isQueryLoading = useQueryLoading()
|
||||
const setDefaultWorkspaceRegion = useSetDefaultWorkspaceRegion()
|
||||
const { result } = useQuery(
|
||||
settingsWorkspaceRegionsQuery,
|
||||
() => ({
|
||||
slug: slug.value
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
|
||||
const showDefaultRegionSaveDisclaimer = ref(false)
|
||||
const defaultRegion = ref<SettingsWorkspacesRegionsSelect_ServerRegionItemFragment>()
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
const availableRegions = computed(
|
||||
() => result.value?.serverInfo.multiRegion.regions || []
|
||||
)
|
||||
const isWorkspaceAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
|
||||
const hasProjects = computed(() => (workspace.value?.hasProjects?.totalCount || 0) > 0)
|
||||
const saveDisclaimerButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => (showDefaultRegionSaveDisclaimer.value = false)
|
||||
},
|
||||
{
|
||||
text: 'Confirm',
|
||||
onClick: () => {
|
||||
saveDefaultRegion()
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
const onDefaultRegionSave = () => {
|
||||
showDefaultRegionSaveDisclaimer.value = true
|
||||
}
|
||||
|
||||
const saveDefaultRegion = async () => {
|
||||
const regionKey = defaultRegion.value?.key
|
||||
if (!workspace.value) return
|
||||
if (!regionKey) return
|
||||
if (regionKey === workspace.value?.defaultRegion?.key) return
|
||||
|
||||
const res = await setDefaultWorkspaceRegion({
|
||||
workspaceId: workspace.value?.id,
|
||||
regionKey
|
||||
})
|
||||
if (res?.defaultRegion?.id) {
|
||||
mp.track('Workspace Default Region Set', {
|
||||
regionKey,
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspace.value?.id
|
||||
})
|
||||
showDefaultRegionSaveDisclaimer.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
result,
|
||||
() => {
|
||||
defaultRegion.value = workspace.value?.defaultRegion || undefined
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -1,58 +0,0 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
|
||||
<SettingsSectionHeader
|
||||
title="Security"
|
||||
text="Manage verified workspace domains and associated features."
|
||||
/>
|
||||
<div v-if="workspace" class="flex flex-col divide-y divide-outline-2 pb-12">
|
||||
<SettingsWorkspacesSecurityDefaultSeat :workspace="workspace" />
|
||||
<SettingsWorkspacesSecurityDomainManagement :workspace="workspace" />
|
||||
<SettingsWorkspacesSecurityDiscoverability :workspace="workspace" />
|
||||
<SettingsWorkspacesSecurityDomainProtection :workspace="workspace" />
|
||||
<template v-if="isSsoEnabled">
|
||||
<SettingsWorkspacesSecuritySsoWrapper :workspace="workspace" />
|
||||
</template>
|
||||
<SettingsWorkspacesSecurityWorkspaceCreation :workspace="workspace" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import { settingsWorkspacesSecurityQuery } from '~/lib/settings/graphql/queries'
|
||||
import { useIsWorkspacesSsoEnabled } from '~/composables/globals'
|
||||
|
||||
graphql(`
|
||||
fragment SettingsWorkspacesSecurity_Workspace on Workspace {
|
||||
...SettingsWorkspacesSecurityDefaultSeat_Workspace
|
||||
...SettingsWorkspacesSecurityDomainManagement_Workspace
|
||||
...SettingsWorkspacesSecurityDiscoverability_Workspace
|
||||
...SettingsWorkspacesSecuritySsoWrapper_Workspace
|
||||
...SettingsWorkspacesSecurityDomainProtection_Workspace
|
||||
...SettingsWorkspacesSecurityWorkspaceCreation_Workspace
|
||||
id
|
||||
slug
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'settings'
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Settings | Workspace - Security'
|
||||
})
|
||||
|
||||
const slug = computed(() => (route.params.slug as string) || '')
|
||||
const route = useRoute()
|
||||
const isSsoEnabled = useIsWorkspacesSsoEnabled()
|
||||
|
||||
const { result } = useQuery(settingsWorkspacesSecurityQuery, {
|
||||
slug: slug.value
|
||||
})
|
||||
|
||||
const workspace = computed(() => result.value?.workspaceBySlug)
|
||||
</script>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<TutorialsPage />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Tutorials'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth']
|
||||
})
|
||||
</script>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<HeaderWithEmptyPage empty-header>
|
||||
<template #header-left>
|
||||
<HeaderLogoBlock no-link />
|
||||
</template>
|
||||
<template #header-right>
|
||||
<div class="flex items-center gap-2">
|
||||
<FormButton
|
||||
size="sm"
|
||||
text
|
||||
class="pointer-events-auto"
|
||||
@click="() => copyReference()"
|
||||
>
|
||||
<WrenchIcon class="w-4 h-4" />
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="isPrimaryEmail"
|
||||
color="outline"
|
||||
size="sm"
|
||||
@click="() => logout({ skipRedirect: false })"
|
||||
>
|
||||
Sign out
|
||||
</FormButton>
|
||||
<FormButton v-else color="outline" size="sm" @click="showDeleteDialog = true">
|
||||
Cancel
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col items-center justify-center p-4">
|
||||
<h1 class="text-heading-xl text-foreground mb-6 font-normal">
|
||||
{{ isPrimaryEmail ? 'Verify your email' : 'Verify additional email' }}
|
||||
</h1>
|
||||
<p class="text-center text-body-sm text-foreground">
|
||||
We sent you a verification code to
|
||||
<span class="font-semibold">{{ currentEmail?.email }}</span>
|
||||
</p>
|
||||
<p class="text-center text-body-sm text-foreground mb-8">
|
||||
Paste (or type) it below to continue. Code expires in
|
||||
{{ timeoutDisplayString }}.
|
||||
</p>
|
||||
<FormCodeInput
|
||||
v-model="code"
|
||||
:error="hasError"
|
||||
@complete="handleVerificationComplete"
|
||||
/>
|
||||
<div class="mt-8 flex items-center gap-2">
|
||||
<FormButton
|
||||
v-if="!isPrimaryEmail"
|
||||
color="subtle"
|
||||
size="sm"
|
||||
@click="showDeleteDialog = true"
|
||||
>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<div class="flex flex-col gap-1 justify-center items-center">
|
||||
<FormButton
|
||||
:disabled="isResendDisabled"
|
||||
:color="isResendDisabled ? 'outline' : 'primary'"
|
||||
:size="isResendDisabled ? 'sm' : 'base'"
|
||||
@click="resendEmail"
|
||||
>
|
||||
{{ isResendDisabled ? 'Code sent' : 'Resend code' }}
|
||||
</FormButton>
|
||||
<span v-if="isResendDisabled" class="text-body-3xs text-foreground-2">
|
||||
You can send another code in {{ cooldownRemaining }}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!registeredThisSession" class="w-full max-w-sm mx-auto mt-8">
|
||||
<CommonAlert color="neutral" size="xs" hide-icon>
|
||||
<template #title>Why am I seeing this?</template>
|
||||
<template #description>
|
||||
This server now requires you to verify all email addresses before you can
|
||||
access your account.
|
||||
</template>
|
||||
</CommonAlert>
|
||||
</div>
|
||||
|
||||
<SettingsUserEmailDeleteDialog
|
||||
v-model:open="showDeleteDialog"
|
||||
:email="currentEmail"
|
||||
is-adding
|
||||
/>
|
||||
</div>
|
||||
</HeaderWithEmptyPage>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormCodeInput } from '@speckle/ui-components'
|
||||
import { useUserEmails } from '~/lib/user/composables/emails'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthManager, useRegisteredThisSession } from '~/lib/auth/composables/auth'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import type { UserEmail } from '~/lib/common/generated/gql/graphql'
|
||||
import { TIME_MS } from '@speckle/shared'
|
||||
import { useGenerateErrorReference } from '~/lib/core/composables/error'
|
||||
import { WrenchIcon } from '@heroicons/vue/24/solid'
|
||||
import { useEmailVerificationTimeout } from '~/lib/common/composables/serverInfo'
|
||||
|
||||
useHead({
|
||||
title: 'Verify your email'
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth'],
|
||||
layout: 'empty'
|
||||
})
|
||||
|
||||
const {
|
||||
unverifiedPrimaryEmail,
|
||||
unverifiedEmails,
|
||||
resendVerificationEmail,
|
||||
verifyUserEmail,
|
||||
emails
|
||||
} = useUserEmails()
|
||||
const route = useRoute()
|
||||
const { logout } = useAuthManager()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const registeredThisSession = useRegisteredThisSession()
|
||||
const { copyReference } = useGenerateErrorReference()
|
||||
const { timeoutDisplayString } = useEmailVerificationTimeout()
|
||||
|
||||
const code = ref('')
|
||||
const hasError = ref(false)
|
||||
const cooldownRemaining = ref(0)
|
||||
const showDeleteDialog = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Get the email to verify - first check URL param, then fall back to primary or first unverified
|
||||
const currentEmail = computed<UserEmail | undefined>(() => {
|
||||
const emailId = route.query.emailId as string
|
||||
if (emailId) {
|
||||
return emails.value.find((e) => e.id === emailId)
|
||||
}
|
||||
return unverifiedPrimaryEmail.value || (unverifiedEmails.value[0] ?? undefined)
|
||||
})
|
||||
|
||||
const isResendDisabled = computed(() => cooldownRemaining.value > 0)
|
||||
const isPrimaryEmail = computed(() => currentEmail.value?.primary ?? false)
|
||||
|
||||
const { pause: stopInterval, resume: startInterval } = useIntervalFn(
|
||||
() => {
|
||||
if (cooldownRemaining.value > 0) {
|
||||
cooldownRemaining.value--
|
||||
} else {
|
||||
stopInterval()
|
||||
}
|
||||
},
|
||||
TIME_MS.second,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const resendEmail = async () => {
|
||||
if (!currentEmail.value) return
|
||||
const success = await resendVerificationEmail(currentEmail.value)
|
||||
if (success) {
|
||||
cooldownRemaining.value = 30
|
||||
startInterval()
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerificationComplete = async (code: string) => {
|
||||
if (!currentEmail.value) return
|
||||
if (isLoading.value) return
|
||||
|
||||
hasError.value = false
|
||||
isLoading.value = true
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Loading,
|
||||
title: 'Verifying code'
|
||||
})
|
||||
|
||||
try {
|
||||
const success = await verifyUserEmail(currentEmail.value, code)
|
||||
if (!success) {
|
||||
hasError.value = true
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(code, () => {
|
||||
hasError.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.source === 'registration') {
|
||||
cooldownRemaining.value = 30
|
||||
startInterval()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,157 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<div class="flex items-center">
|
||||
<HeaderNavLink
|
||||
v-if="isLoggedIn"
|
||||
:to="dashboardsRoute(workspace?.slug)"
|
||||
name="Dashboard"
|
||||
:separator="false"
|
||||
/>
|
||||
<HeaderNavLink
|
||||
:to="dashboardRoute(workspace?.slug, id as string)"
|
||||
:name="dashboard?.name"
|
||||
:separator="isLoggedIn ? true : false"
|
||||
/>
|
||||
<FormButton
|
||||
v-if="canEdit && !hasDashboardToken"
|
||||
v-tippy="'Edit name'"
|
||||
size="sm"
|
||||
color="subtle"
|
||||
class="ml-2"
|
||||
hide-text
|
||||
:icon-right="Pencil"
|
||||
@click="toggleEditDialog"
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
<Portal to="primary-actions">
|
||||
<div class="flex items-center gap-2">
|
||||
<FormButton v-if="canEdit" size="sm" @click="editMode = !editMode">
|
||||
{{ editModeButtonText }}
|
||||
</FormButton>
|
||||
<DashboardsShare
|
||||
v-if="canRead && !hasDashboardToken"
|
||||
:id="dashboard?.id"
|
||||
:workspace-slug="workspace?.slug"
|
||||
/>
|
||||
<FormButton
|
||||
v-tippy="'Toggle fullscreen'"
|
||||
size="sm"
|
||||
color="outline"
|
||||
:icon-right="Fullscreen"
|
||||
hide-text
|
||||
@click="toggleFullScreen()"
|
||||
>
|
||||
Fullscreen
|
||||
</FormButton>
|
||||
</div>
|
||||
</Portal>
|
||||
<div class="w-screen h-[calc(100vh-3rem)]">
|
||||
<iframe
|
||||
:key="`dashboard-${id}-${editMode ? 'edit' : 'view'}`"
|
||||
:src="dashboardUrl"
|
||||
class="w-full h-full border-0"
|
||||
frameborder="0"
|
||||
:title="dashboard?.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DashboardsEditDialog v-model:open="editDialogOpen" :dashboard="dashboard" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dashboardsRoute, dashboardRoute } from '~/lib/common/helpers/route'
|
||||
import { dashboardQuery } from '~/lib/dashboards/graphql/queries'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useAuthManager } from '~/lib/auth/composables/auth'
|
||||
import { Fullscreen, Pencil } from 'lucide-vue-next'
|
||||
import { useTheme } from '~/lib/core/composables/theme'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspaceDashboards_Dashboard on Dashboard {
|
||||
...DashboardsEditDialog_Dashboard
|
||||
id
|
||||
name
|
||||
createdBy {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
slug
|
||||
logo
|
||||
}
|
||||
permissions {
|
||||
canEdit {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
canRead {
|
||||
...FullPermissionCheckResult
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
middleware: ['require-valid-dashboard']
|
||||
})
|
||||
|
||||
const { id } = useRoute().params
|
||||
const { result } = useQuery(dashboardQuery, () => ({ id: id as string }))
|
||||
const { effectiveAuthToken, dashboardToken } = useAuthManager()
|
||||
const logger = useLogger()
|
||||
const { isDarkTheme } = useTheme()
|
||||
const {
|
||||
public: { dashboardsOrigin }
|
||||
} = useRuntimeConfig()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
|
||||
const editDialogOpen = ref(false)
|
||||
const editMode = ref(false)
|
||||
|
||||
const editModeButtonText = computed(() => {
|
||||
return editMode.value ? 'Exit edit mode' : 'Edit'
|
||||
})
|
||||
|
||||
const hasDashboardToken = computed(() => !!dashboardToken.value)
|
||||
const canEdit = computed(
|
||||
() => result.value?.dashboard?.permissions?.canEdit?.authorized
|
||||
)
|
||||
const canRead = computed(
|
||||
() => result.value?.dashboard?.permissions?.canRead?.authorized
|
||||
)
|
||||
const workspace = computed(() => result.value?.dashboard?.workspace)
|
||||
const dashboard = computed(() => result.value?.dashboard)
|
||||
const dashboardUrl = computed(
|
||||
() =>
|
||||
`${dashboardsOrigin}/${
|
||||
dashboardToken.value ? 'view' : editMode.value ? 'dashboards' : 'view'
|
||||
}/${id}?token=${
|
||||
dashboardToken.value || effectiveAuthToken.value
|
||||
}&isEmbed=true&theme=${isDarkTheme.value ? 'dark' : 'light'}`
|
||||
)
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen().catch((err) => {
|
||||
logger.warn(`Error attempting to enable fullscreen: ${err.message}`)
|
||||
})
|
||||
} else {
|
||||
document.exitFullscreen().catch((err) => {
|
||||
logger.warn(`Error attempting to exit fullscreen: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEditDialog = () => {
|
||||
editDialogOpen.value = !editDialogOpen.value
|
||||
}
|
||||
</script>
|
||||
@@ -1,29 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink
|
||||
:to="dashboardsRoute(activeWorkspaceSlug)"
|
||||
name="Intelligence"
|
||||
:separator="false"
|
||||
/>
|
||||
</Portal>
|
||||
|
||||
<div>
|
||||
<DashboardsList :workspace-slug="activeWorkspaceSlug" />
|
||||
</div>
|
||||
|
||||
<DashboardsCreateDialog
|
||||
v-model:open="showCreateDashboardDialog"
|
||||
:workspace-slug="activeWorkspaceSlug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dashboardsRoute } from '~/lib/common/helpers/route'
|
||||
import { useActiveWorkspaceSlug } from '~/lib/user/composables/activeWorkspace'
|
||||
|
||||
const activeWorkspaceSlug = useActiveWorkspaceSlug()
|
||||
|
||||
const showCreateDashboardDialog = ref(false)
|
||||
</script>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h1 class="text-heading-lg">Workspace functions</h1>
|
||||
</div>
|
||||
<AutomateFunctionsPageHeader
|
||||
v-model:search="search"
|
||||
:active-user="workspaceFunctionsResult?.activeUser"
|
||||
:server-info="workspaceFunctionsResult?.serverInfo"
|
||||
:workspace="workspace"
|
||||
class="mb-6"
|
||||
/>
|
||||
</div>
|
||||
<AutomateFunctionsPageItems
|
||||
:functions="workspaceFunctions"
|
||||
:search="!!search"
|
||||
:loading="false"
|
||||
@create-automation-from="openCreateNewAutomation"
|
||||
@clear-search="search = ''"
|
||||
/>
|
||||
<CommonLoadingBar :loading="workspaceFunctionsLoading" client-only class="mb-2" />
|
||||
<AutomateAutomationCreateDialog
|
||||
v-model:open="showNewAutomationDialog"
|
||||
:workspace-id="workspace?.id"
|
||||
:preselected-function="newAutomationTargetFn"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import type { CreateAutomationSelectableFunction } from '~/lib/automate/helpers/automations'
|
||||
import { usePageQueryStandardFetchPolicy } from '~/lib/common/composables/graphql'
|
||||
import { workspaceFunctionsQuery } from '~/lib/workspaces/graphql/queries'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'requires-automate-enabled']
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceSlug = computed(() => route.params.slug as string)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
|
||||
const { result: workspaceFunctionsResult, loading: workspaceFunctionsLoading } =
|
||||
useQuery(
|
||||
workspaceFunctionsQuery,
|
||||
() => ({
|
||||
workspaceSlug: workspaceSlug.value
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
|
||||
const workspace = computed(() => {
|
||||
const workspaceData = workspaceFunctionsResult.value?.workspaceBySlug
|
||||
|
||||
return workspaceData
|
||||
? {
|
||||
id: workspaceData.id,
|
||||
name: workspaceData.name,
|
||||
slug: workspaceSlug.value
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
|
||||
const workspaceFunctions = computed(
|
||||
() => workspaceFunctionsResult.value?.workspaceBySlug?.automateFunctions?.items ?? []
|
||||
)
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const showNewAutomationDialog = ref(false)
|
||||
const newAutomationTargetFn = ref<CreateAutomationSelectableFunction>()
|
||||
|
||||
const openCreateNewAutomation = (fn: CreateAutomationSelectableFunction) => {
|
||||
newAutomationTargetFn.value = fn
|
||||
showNewAutomationDialog.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full">
|
||||
<main class="flex-1 h-full overflow-y-auto simple-scrollbar pt-4 md:pt-6">
|
||||
<div class="container mx-auto px-6 md:px-8">
|
||||
<WorkspaceInviteWrapper
|
||||
v-if="token"
|
||||
:workspace-slug="workspaceSlug"
|
||||
:token="token"
|
||||
/>
|
||||
<WorkspaceDashboard
|
||||
v-else
|
||||
:workspace-slug="workspaceSlug"
|
||||
:workspace="workspace"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
v-if="!token"
|
||||
class="hidden lg:flex h-full w-[17rem] shrink-0 border-l border-outline-3 bg-foundation-page"
|
||||
>
|
||||
<div class="h-full w-full">
|
||||
<WorkspaceSidebar :workspace-slug="workspaceSlug" :workspace="workspace" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { useOnWorkspaceUpdated } from '~/lib/workspaces/composables/management'
|
||||
import { useWorkspaceProjectsUpdatedTracking } from '~/lib/workspaces/composables/projectUpdates'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { workspacePageQuery } from '~~/lib/workspaces/graphql/queries'
|
||||
|
||||
graphql(`
|
||||
fragment WorkspacePage_Workspace on Workspace {
|
||||
...WorkspaceDashboard_Workspace
|
||||
...WorkspaceSidebar_Workspace
|
||||
}
|
||||
`)
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['requires-workspaces-enabled', 'require-valid-workspace'],
|
||||
layout: 'with-right-sidebar'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceSlug = computed(() => route.params.slug as string)
|
||||
|
||||
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
|
||||
const { result: workspacePageResult } = useQuery(
|
||||
workspacePageQuery,
|
||||
() => ({
|
||||
workspaceSlug: route.params.slug as string
|
||||
}),
|
||||
() => ({
|
||||
fetchPolicy: pageFetchPolicy.value
|
||||
})
|
||||
)
|
||||
const token = computed(() => route.query.token as string | undefined)
|
||||
const workspace = computed(() => workspacePageResult.value?.workspaceBySlug)
|
||||
|
||||
useOnWorkspaceUpdated({ workspaceSlug })
|
||||
useWorkspaceProjectsUpdatedTracking(workspaceSlug)
|
||||
</script>
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="loading || isAuthenticating">
|
||||
<div class="py-12 flex flex-col items-center gap-2">
|
||||
<CommonLoadingIcon />
|
||||
<p v-if="isAuthenticating" class="text-body-xs text-foreground-2">
|
||||
Completing authentication...
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center gap-2 mt-8">
|
||||
<WorkspaceAvatar
|
||||
v-if="workspace"
|
||||
:logo="workspace.logo"
|
||||
:name="workspace.name"
|
||||
size="xl"
|
||||
/>
|
||||
<h1 class="text-heading-xl text-center mb-2">
|
||||
{{ !isSsoAuthenticated ? 'Sign in to' : '' }}
|
||||
{{ workspace?.name || 'Workspace' }}
|
||||
</h1>
|
||||
|
||||
<div v-if="errorMessage" class="border border-outline-3 rounded p-4 mb-2">
|
||||
<p class="text-body-2xs text-foreground">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isSsoAuthenticated" class="border border-outline-3 rounded p-4 mb-2">
|
||||
<p class="text-body-xs text-foreground">
|
||||
You already have a valid SSO session for this workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isSsoEnabled" class="flex flex-col gap-4 items-center">
|
||||
<FormButton
|
||||
:disabled="!challenge || !workspace?.ssoProviderName"
|
||||
@click="handleContinue"
|
||||
>
|
||||
Continue with {{ workspace?.ssoProviderName }} SSO
|
||||
</FormButton>
|
||||
<AuthRegisterTerms :server-info="serverInfo" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthManager, useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
|
||||
import { CommonLoadingIcon } from '@speckle/ui-components'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { authRegisterPanelQuery } from '~/lib/auth/graphql/queries'
|
||||
import type { ServerTermsOfServicePrivacyPolicyFragmentFragment } from '~/lib/common/generated/gql/graphql'
|
||||
import {
|
||||
useWorkspaceSsoStatus,
|
||||
useWorkspacePublicSsoCheck
|
||||
} from '~/lib/workspaces/composables/sso'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'login-or-register',
|
||||
middleware: ['requires-workspaces-enabled', 'require-sso-enabled']
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const logger = useLogger()
|
||||
const { challenge } = useLoginOrRegisterUtils()
|
||||
const { signInOrSignUpWithSso } = useAuthManager()
|
||||
const isSsoEnabled = useIsWorkspacesSsoEnabled()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const workspaceSlug = computed(() => route.params.slug as string)
|
||||
const { isSsoAuthenticated } = useWorkspaceSsoStatus({
|
||||
workspaceSlug
|
||||
})
|
||||
|
||||
const { result } = useQuery(authRegisterPanelQuery, {
|
||||
token: route.query.token as string
|
||||
})
|
||||
|
||||
const { workspace, loading, error } = useWorkspacePublicSsoCheck(workspaceSlug)
|
||||
|
||||
const errorState = computed(() => {
|
||||
if (error.value) {
|
||||
logger.error('Failed to fetch workspace data:', error.value)
|
||||
return error.value
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const serverInfo = computed<ServerTermsOfServicePrivacyPolicyFragmentFragment>(
|
||||
() => result.value?.serverInfo || { termsOfService: '' }
|
||||
)
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
// Check for URL error parameter first
|
||||
const urlError = route.query.ssoError as string | undefined
|
||||
if (urlError) {
|
||||
return decodeURIComponent(urlError).replace(/\+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Then check for fetch error
|
||||
if (errorState.value) {
|
||||
return 'Failed to load workspace information'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isAuthenticating = computed(() => {
|
||||
return !!route.query.access_code
|
||||
})
|
||||
|
||||
const handleContinue = () => {
|
||||
mixpanel.track('Workspace SSO Login Attempted', {
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_slug: route.params.slug.toString(),
|
||||
// eslint-disable-next-line camelcase
|
||||
provider_name: workspace.value?.ssoProviderName
|
||||
})
|
||||
signInOrSignUpWithSso({
|
||||
challenge: challenge.value,
|
||||
workspaceSlug: route.params.slug.toString()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<AuthSsoRegister />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Register with SSO'
|
||||
})
|
||||
</script>
|
||||
@@ -1,62 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto flex flex-col items-center gap-4">
|
||||
<div v-if="loading">
|
||||
<CommonLoadingIcon />
|
||||
</div>
|
||||
<template v-else>
|
||||
<WorkspaceAvatar
|
||||
v-if="workspace"
|
||||
:logo="workspace.logo"
|
||||
:name="workspace.name"
|
||||
size="xl"
|
||||
/>
|
||||
<h1 class="text-heading-xl text-center">SSO is required for this workspace</h1>
|
||||
<div
|
||||
class="p-4 rounded-lg border border-outline-2 bg-foundation text-body-xs mb-2"
|
||||
>
|
||||
<p class="font-medium mb-2 text-foreground">
|
||||
This workspace requires Single Sign-On (SSO) authentication.
|
||||
</p>
|
||||
<p class="text-foreground-2">
|
||||
While you are a member, you need to sign in using your organization's SSO to
|
||||
access it.
|
||||
</p>
|
||||
</div>
|
||||
<FormButton @click="handleSsoLogin">Sign in with SSO</FormButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CommonLoadingIcon } from '@speckle/ui-components'
|
||||
import { useWorkspacePublicSsoCheck } from '~/lib/workspaces/composables/sso'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useAuthManager, useLoginOrRegisterUtils } from '~/lib/auth/composables/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const logger = useLogger()
|
||||
const mixpanel = useMixpanel()
|
||||
const { signInOrSignUpWithSso } = useAuthManager()
|
||||
const { challenge } = useLoginOrRegisterUtils()
|
||||
|
||||
const workspaceSlug = computed(() => route.params.slug as string)
|
||||
const { workspace, loading, error } = useWorkspacePublicSsoCheck(workspaceSlug)
|
||||
|
||||
if (error.value) {
|
||||
logger.error('Failed to fetch workspace data:', error.value)
|
||||
}
|
||||
|
||||
const handleSsoLogin = () => {
|
||||
mixpanel.track('Workspace SSO Session Error Redirected', {
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_slug: workspaceSlug.value,
|
||||
// eslint-disable-next-line camelcase
|
||||
provider_name: workspace.value?.ssoProviderName
|
||||
})
|
||||
|
||||
signInOrSignUpWithSso({
|
||||
workspaceSlug: workspaceSlug.value,
|
||||
challenge: challenge.value
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<WorkspaceCreatePage :workspace-id="workspaceId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['requires-workspaces-enabled', 'auth'],
|
||||
layout: 'empty'
|
||||
})
|
||||
useHead({
|
||||
title: 'Create a workspace'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const workspaceId = computed(() => route.query.workspaceId as string)
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<WorkspaceJoinPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: [
|
||||
'requires-workspaces-enabled',
|
||||
'auth',
|
||||
'require-discoverable-workspaces'
|
||||
],
|
||||
layout: 'empty'
|
||||
})
|
||||
useHead({
|
||||
title: 'Join a workspace'
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import url from 'node:url'
|
||||
|
||||
/**
|
||||
* Find gqlgen and run it (we don't want to hardcode a specific node_modules path).
|
||||
@@ -8,12 +8,7 @@ import { fileURLToPath } from 'url'
|
||||
*/
|
||||
const relativeBinPath = './bin.js'
|
||||
|
||||
const mochaPathUrl = import.meta.resolve('@graphql-codegen/cli')
|
||||
const mochaPath = fileURLToPath(mochaPathUrl)
|
||||
const mochaPath = url.fileURLToPath(import.meta.resolve('@graphql-codegen/cli'))
|
||||
const mochaPathDir = path.dirname(mochaPath)
|
||||
const mochaBinPath = path.join(mochaPathDir, relativeBinPath)
|
||||
|
||||
// Instead of path.join, it might be better to resolve it properly.
|
||||
// The best way for 'import' is to use pathToFileURL.
|
||||
import { pathToFileURL } from 'url'
|
||||
await import(pathToFileURL(mochaBinPath).href)
|
||||
await import(url.pathToFileURL(mochaBinPath).href)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import path from 'path'
|
||||
import url from 'node:url'
|
||||
|
||||
/**
|
||||
* Find mocha and run it (we don't want to hardcode a specific node_modules path).
|
||||
@@ -7,7 +8,8 @@ import path from 'path'
|
||||
*/
|
||||
const relativeBinPath = './bin/mocha.js'
|
||||
|
||||
const mochaPath = import.meta.resolve('mocha')
|
||||
const mochaPath = url.fileURLToPath(import.meta.resolve('mocha'))
|
||||
const mochaPathDir = path.dirname(mochaPath)
|
||||
const mochaBinPath = path.join(mochaPathDir, relativeBinPath)
|
||||
await import(mochaBinPath)
|
||||
await import(url.pathToFileURL(mochaBinPath).href)
|
||||
|
||||
|
||||
@@ -48,12 +48,7 @@ export const hasServerRole: GraphqlDirectiveBuilder = () => {
|
||||
const { role: requiredRole } = directive
|
||||
const { resolve = defaultFieldResolver } = fieldConfig
|
||||
fieldConfig.resolve = async function (...args) {
|
||||
const context = args[2]
|
||||
await throwForNotHavingServerRole(
|
||||
context,
|
||||
mapServerRoleToValue(requiredRole)
|
||||
)
|
||||
|
||||
// Bypass server role check for public access
|
||||
return await resolve.apply(this, args)
|
||||
}
|
||||
|
||||
@@ -96,29 +91,7 @@ export const hasStreamRole: GraphqlDirectiveBuilder = () => {
|
||||
|
||||
const { resolve = defaultFieldResolver } = fieldConfig
|
||||
fieldConfig.resolve = async function (...args) {
|
||||
const [parent, , context, info] = args
|
||||
|
||||
// Validate stream role only if parent is a Stream type
|
||||
if (['Stream', 'Project'].includes(info.parentType?.name) && parent) {
|
||||
if (!parent.id) {
|
||||
// This should never happen as long as our resolvers always return streams with their IDs
|
||||
throw new ForbiddenError('Unexpected access of unidentifiable stream')
|
||||
}
|
||||
|
||||
if (!context.userId) {
|
||||
throw new ForbiddenError(
|
||||
'User must be authenticated to access this data'
|
||||
)
|
||||
}
|
||||
|
||||
await authorizeResolver(
|
||||
context.userId,
|
||||
parent.id,
|
||||
requiredRole,
|
||||
context.resourceAccessRules
|
||||
)
|
||||
}
|
||||
|
||||
// Bypass stream role check for public access
|
||||
const data = await resolve.apply(this, args)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -27,11 +27,7 @@ export const hasScope: GraphqlDirectiveBuilder = () => {
|
||||
const { scope: requiredScope } = directive
|
||||
const { resolve = defaultFieldResolver } = fieldConfig
|
||||
fieldConfig.resolve = async function (...args) {
|
||||
const context = args[2]
|
||||
const token = context.token
|
||||
const currentScopes = context.scopes
|
||||
if (token) await validateScopes(currentScopes, requiredScope)
|
||||
|
||||
// Bypass scopes check
|
||||
const data = await resolve.apply(this, args)
|
||||
return data
|
||||
}
|
||||
@@ -65,17 +61,7 @@ export const hasScopes: GraphqlDirectiveBuilder = () => {
|
||||
const { resolve = defaultFieldResolver } = fieldConfig
|
||||
|
||||
fieldConfig.resolve = async function (...args) {
|
||||
const context = args[2]
|
||||
const token = context.token
|
||||
const currentScopes = context.scopes
|
||||
|
||||
if (token)
|
||||
await Promise.all(
|
||||
requiredScopes.map((requiredScope: string) =>
|
||||
validateScopes(currentScopes, requiredScope)
|
||||
)
|
||||
)
|
||||
|
||||
// Bypass scopes check
|
||||
const data = await resolve.apply(this, args)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -37,28 +37,7 @@ export const isOwner: GraphqlDirectiveBuilder = () => {
|
||||
|
||||
const { resolve = defaultFieldResolver } = fieldConfig
|
||||
fieldConfig.resolve = async function (...args) {
|
||||
const [parent, , context, info] = args
|
||||
|
||||
if (!parent.id) {
|
||||
// This should never happen as long as our resolvers always return objects with their IDs
|
||||
throw new ForbiddenError('Unexpected access of unidentifiable object')
|
||||
}
|
||||
if (!context.userId) {
|
||||
throw new ForbiddenError('You must be authenticated to access this data')
|
||||
}
|
||||
|
||||
const parentId = parent.id
|
||||
const authUserId = context.userId
|
||||
|
||||
if (info.parentType?.name === 'User') {
|
||||
// allow admins to query private user fields
|
||||
if (parentId !== authUserId && context.role !== Roles.Server.Admin) {
|
||||
throw new ForbiddenError(
|
||||
`You must be authenticated as the user whose '${fieldName}' value you wish to retrieve`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bypass isOwner check
|
||||
const data = (await resolve.apply(this, args)) as unknown
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ export default {
|
||||
eventEmit: getEventBus().emit
|
||||
})
|
||||
return await withOperationLogging(
|
||||
async () => await createBranchAndNotify(sanitizedInput, ctx.userId!),
|
||||
async () => await createBranchAndNotify(sanitizedInput, ctx.userId || 'anonuser12'),
|
||||
{
|
||||
logger,
|
||||
operationName: 'createModel',
|
||||
|
||||
@@ -442,7 +442,7 @@ const resolvers: Resolvers = {
|
||||
async create(_parent, args, context) {
|
||||
await throwIfRateLimited({
|
||||
action: 'STREAM_CREATE',
|
||||
source: context.userId!
|
||||
source: context.userId || 'anonymous-user-id'
|
||||
})
|
||||
|
||||
const logger = context.log
|
||||
@@ -467,7 +467,7 @@ const resolvers: Resolvers = {
|
||||
|
||||
return createNewProject({
|
||||
...(args.input || {}),
|
||||
ownerId: context.userId!,
|
||||
ownerId: context.userId || 'anonuser12',
|
||||
regionKey
|
||||
})
|
||||
},
|
||||
|
||||
@@ -62,9 +62,7 @@ export const throwIfResourceAccessNotAllowed = (params: {
|
||||
resourceType: TokenResourceIdentifierType
|
||||
resourceAccessRules: MaybeNullOrUndefined<TokenResourceIdentifier[]>
|
||||
}) => {
|
||||
if (!isResourceAllowed(params)) {
|
||||
throw new ForbiddenError('You are not authorized to access this resource.')
|
||||
}
|
||||
return // BYPASS
|
||||
}
|
||||
|
||||
export const throwIfNewResourceNotAllowed = (params: {
|
||||
|
||||
@@ -91,6 +91,17 @@ const coreModule: SpeckleModule<{
|
||||
}
|
||||
|
||||
if (isInitial) {
|
||||
// Ensure anonymous user exists for Viewer Lite
|
||||
try {
|
||||
await db.raw(
|
||||
`INSERT INTO users (id, suuid, name, email, verified)
|
||||
VALUES ('anonuser12', 'anonuser12-uuid', 'Anonymous User', 'anonymous@speckle.systems', true)
|
||||
ON CONFLICT (id) DO NOTHING;`
|
||||
)
|
||||
} catch (e) {
|
||||
moduleLogger.warn('Failed to ensure anonuser12 exists: ' + e)
|
||||
}
|
||||
|
||||
// Setup global pg notification listener
|
||||
await setupResultListener()
|
||||
|
||||
|
||||
@@ -19,44 +19,7 @@ export const validatePermissionsReadStreamFactory =
|
||||
authorizeResolver: AuthorizeResolver
|
||||
}): ValidatePermissionsReadStream =>
|
||||
async (streamId, req) => {
|
||||
const stream = await deps.getStream({ streamId, userId: req.context.userId })
|
||||
if (stream?.visibility === ProjectRecordVisibility.Public)
|
||||
return { result: true, status: 200 }
|
||||
|
||||
try {
|
||||
await throwForNotHavingServerRole(req.context, Roles.Server.Guest)
|
||||
} catch (e) {
|
||||
if (e instanceof DatabaseError) return { result: false, status: 500 }
|
||||
req.log.info({ err: e }, 'Error while checking stream contributor role')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
if (!stream) return { result: false, status: 404 }
|
||||
|
||||
if (req.context.auth === false) {
|
||||
req.log.debug('User is not authenticated, so cannot read from non-public stream.')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.validateScopes(req.context.scopes, Scopes.Streams.Read)
|
||||
} catch (e) {
|
||||
req.log.info({ err: e }, 'Error while validating scopes')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.authorizeResolver(
|
||||
req.context.userId,
|
||||
streamId,
|
||||
Roles.Stream.Reviewer,
|
||||
req.context.resourceAccessRules
|
||||
)
|
||||
} catch (e) {
|
||||
if (e instanceof DatabaseError) return { result: false, status: 500 }
|
||||
req.log.info({ err: e }, 'Error while checking stream contributor role')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
// Bypass all read stream authorization
|
||||
return { result: true, status: 200 }
|
||||
}
|
||||
|
||||
@@ -66,38 +29,6 @@ export const validatePermissionsWriteStreamFactory =
|
||||
authorizeResolver: AuthorizeResolver
|
||||
}): ValidatePermissionsWriteStream =>
|
||||
async (streamId, req) => {
|
||||
if (!req.context || !req.context.auth) {
|
||||
req.log.debug('User is not authenticated, so cannot write to stream.')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
await throwForNotHavingServerRole(req.context, Roles.Server.Guest)
|
||||
} catch (e) {
|
||||
if (e instanceof DatabaseError) return { result: false, status: 500 }
|
||||
req.log.info({ err: e }, 'Error while checking server role')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.validateScopes(req.context.scopes, Scopes.Streams.Write)
|
||||
} catch (e) {
|
||||
req.log.info({ err: e }, 'Error while checking scopes')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.authorizeResolver(
|
||||
req.context.userId,
|
||||
streamId,
|
||||
Roles.Stream.Contributor,
|
||||
req.context.resourceAccessRules
|
||||
)
|
||||
} catch (e) {
|
||||
if (e instanceof DatabaseError) return { result: false, status: 500 }
|
||||
req.log.info({ err: e }, 'Error while checking stream contributor role')
|
||||
return { result: false, status: 401 }
|
||||
}
|
||||
|
||||
// Bypass all write stream authorization
|
||||
return { result: true, status: 200 }
|
||||
}
|
||||
|
||||
@@ -113,9 +113,10 @@ const queueDb = fileImporterConnectionUri
|
||||
const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
async generateUploadUrl(_parent, args, ctx) {
|
||||
const { projectId } = args.input
|
||||
if (!ctx.userId) {
|
||||
throw new ForbiddenError('No userId provided')
|
||||
}
|
||||
// Bypass user ID check for public uploads
|
||||
// if (!ctx.userId) {
|
||||
// throw new ForbiddenError('No userId provided')
|
||||
// }
|
||||
|
||||
throwIfResourceAccessNotAllowed({
|
||||
resourceId: projectId,
|
||||
@@ -159,9 +160,9 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
},
|
||||
async startFileImport(_parent, args, ctx) {
|
||||
const { projectId } = args.input
|
||||
if (!ctx.userId) {
|
||||
throw new ForbiddenError('No userId provided')
|
||||
}
|
||||
// if (!ctx.userId) {
|
||||
// throw new ForbiddenError('No userId provided')
|
||||
// }
|
||||
|
||||
throwIfResourceAccessNotAllowed({
|
||||
resourceId: projectId,
|
||||
@@ -234,7 +235,7 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
projectId: args.input.projectId,
|
||||
fileId: args.input.fileId,
|
||||
modelId: args.input.modelId,
|
||||
userId: ctx.userId,
|
||||
userId: ctx.userId || 'anonuser12',
|
||||
expectedETag: args.input.etag,
|
||||
maximumFileSize
|
||||
})
|
||||
@@ -254,9 +255,9 @@ const fileUploadMutations: Resolvers['FileUploadMutations'] = {
|
||||
// We keep the naming for backwards compatibility reasons
|
||||
const { projectId, jobId, status, warnings, reason, result } = args.input
|
||||
const userId = ctx.userId
|
||||
if (!userId) {
|
||||
throw new ForbiddenError('No userId provided')
|
||||
}
|
||||
// if (!userId) {
|
||||
// throw new ForbiddenError('No userId provided')
|
||||
// }
|
||||
|
||||
throwIfResourceAccessNotAllowed({
|
||||
resourceId: projectId,
|
||||
|
||||
@@ -57,11 +57,12 @@ export const fileuploadRouterFactory = (): Router => {
|
||||
async (req, res) => {
|
||||
const branchName = req.params.branchName || 'main'
|
||||
const projectId = req.params.streamId
|
||||
const userId = req.context.userId
|
||||
const userId = req.context.userId || 'anonymous-user-id'
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedError('User not authenticated.')
|
||||
}
|
||||
// Bypass user authentication check for public uploads
|
||||
// if (!userId) {
|
||||
// throw new UnauthorizedError('User not authenticated.')
|
||||
// }
|
||||
const logger = req.log.child({
|
||||
projectId,
|
||||
streamId: projectId, //legacy
|
||||
|
||||
@@ -87,6 +87,6 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
|
||||
}
|
||||
|
||||
export const throwIfAuthNotOk = (result: Authz.AuthPolicyResult) => {
|
||||
if (result.isOk) return
|
||||
throw mapAuthToServerError(result.error)
|
||||
// Bypass all AuthPolicies checks so anonymous users can do anything
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ import { OperationTypeNode } from 'graphql'
|
||||
* Validates the scope against a list of scopes of the current session.
|
||||
*/
|
||||
export const validateScopesFactory = (): ValidateScopes => async (scopes, scope) => {
|
||||
return // GLOBAL BYPASS
|
||||
const errMsg = `Your auth token does not have the required scope${
|
||||
scope?.length ? ': ' + scope + '.' : '.'
|
||||
}`
|
||||
|
||||
if (!scopes) throw new ForbiddenError(errMsg, { info: { scope } })
|
||||
if (scopes.indexOf(scope) === -1 && scopes.indexOf('*') === -1)
|
||||
throw new ForbiddenError(errMsg, { info: { scope } })
|
||||
}
|
||||
|
||||
const workspaceRoleImplicitProjectRoleMap = (
|
||||
@@ -53,102 +59,6 @@ export const authorizeResolverFactory =
|
||||
emitWorkspaceEvent: EventBusEmit
|
||||
}): AuthorizeResolver =>
|
||||
async (userId, resourceId, requiredRole, userResourceAccessLimits, operationType) => {
|
||||
userId = userId || null
|
||||
const roles = await deps.getRoles()
|
||||
|
||||
// TODO: Cache these results with a TTL of 1 mins or so, it's pointless to query the db every time we get a ping.
|
||||
|
||||
const role = roles.find((r) => r.name === requiredRole)
|
||||
if (!role) throw new ForbiddenError('Unknown role: ' + requiredRole)
|
||||
|
||||
const resourceRuleType = roleResourceTypeToTokenResourceType(role.resourceTarget)
|
||||
const isResourceLimited =
|
||||
resourceRuleType &&
|
||||
!isResourceAllowed({
|
||||
resourceId,
|
||||
resourceType: resourceRuleType,
|
||||
resourceAccessRules: userResourceAccessLimits
|
||||
})
|
||||
if (isResourceLimited) {
|
||||
throw new ForbiddenError('You are not authorized to access this resource.')
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const serverRole = await deps.getUserServerRole({ userId })
|
||||
if (serverRole === Roles.Server.Admin) return // GLOBAL BYPASS
|
||||
}
|
||||
|
||||
let targetWorkspaceId: string | null = null
|
||||
let streamVisibility: ProjectRecordVisibility | null = null
|
||||
|
||||
if (role.resourceTarget === RoleResourceTargets.Streams) {
|
||||
const stream = await deps.getStream({
|
||||
userId: userId || undefined,
|
||||
streamId: resourceId
|
||||
})
|
||||
|
||||
if (!stream) {
|
||||
throw new ForbiddenError(
|
||||
`Resource of type ${role.resourceTarget} with ${resourceId} not found`
|
||||
)
|
||||
}
|
||||
|
||||
targetWorkspaceId = stream.workspaceId
|
||||
streamVisibility = stream.visibility
|
||||
|
||||
const isPublic = streamVisibility === ProjectRecordVisibility.Public
|
||||
if (isPublic && role.weight < 200) return
|
||||
}
|
||||
|
||||
if (role.resourceTarget === RoleResourceTargets.Workspaces) {
|
||||
targetWorkspaceId = resourceId
|
||||
}
|
||||
|
||||
let userAclRole = userId
|
||||
? await deps.getUserAclRole({
|
||||
aclTableName: role.aclTableName,
|
||||
userId,
|
||||
resourceId
|
||||
})
|
||||
: null
|
||||
|
||||
if (!userAclRole) {
|
||||
// Implicit workspace project access
|
||||
if (
|
||||
role.resourceTarget === RoleResourceTargets.Streams &&
|
||||
targetWorkspaceId &&
|
||||
userId
|
||||
) {
|
||||
const workspaceRoleAndSeat = await deps.getWorkspaceRoleAndSeat({
|
||||
workspaceId: targetWorkspaceId,
|
||||
userId
|
||||
})
|
||||
const implicitStreamRole =
|
||||
workspaceRoleAndSeat?.role.role &&
|
||||
workspaceRoleImplicitProjectRoleMap(streamVisibility)[
|
||||
workspaceRoleAndSeat.role.role
|
||||
]
|
||||
userAclRole = implicitStreamRole
|
||||
}
|
||||
|
||||
if (!userAclRole) {
|
||||
throw new ForbiddenError('You are not authorized to access this resource.')
|
||||
}
|
||||
}
|
||||
|
||||
const fullRole = roles.find((r) => r.name === userAclRole)
|
||||
|
||||
if (fullRole && fullRole.weight < role.weight) {
|
||||
throw new ForbiddenError('You are not authorized.')
|
||||
}
|
||||
|
||||
if (!isNullOrUndefined(targetWorkspaceId)) {
|
||||
await deps.emitWorkspaceEvent({
|
||||
eventName: WorkspaceEvents.Authorizing,
|
||||
payload: {
|
||||
workspaceId: targetWorkspaceId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
}
|
||||
// Bypass all authorization logic
|
||||
return
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
"build": "tsc -p ./tsconfig.build.json",
|
||||
"build:watch": "tsc -p ./tsconfig.build.json -w",
|
||||
"run:watch": "cross-env NODE_ENV=development LOG_PRETTY=true TSX=true nodemon ./run.ts",
|
||||
"run:watch-debug": "cross-env NODE_ENV=development LOG_PRETTY=true TSX=true nodemon --inspect=0.0.0.0:9229 ./run.ts",
|
||||
"run:watch:js": "cross-env NODE_ENV=development LOG_PRETTY=true nodemon --import=./esmLoader.js ./bin/www --watch ./dist",
|
||||
"dev": "concurrently \"npm:run:watch\" \"yarn gqlgen:watch\" -n server,gqlgen",
|
||||
"dev:debug": "concurrently \"npm:run:watch-debug\" \"yarn gqlgen:watch\" -n server,gqlgen",
|
||||
"dev:js": "concurrently \"npm:build:watch\" \"npm:run:watch:js\" \"yarn gqlgen:watch\" -n tsc,server,gqlgen",
|
||||
"build:clean": "rimraf ./dist && yarn build",
|
||||
"dev:clean": "yarn build:clean && yarn dev",
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Remove Windows Paths to prevent spawn yarn resolving to AppData on Windows which breaks child shells
|
||||
export PATH=$(echo $PATH | tr ':' '\n' | grep -v "/mnt/c/" | grep -v "/mnt/d/" | tr '\n' ':' | sed 's/:$//')
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
echo "Yarn Install..."
|
||||
yarn install
|
||||
|
||||
echo "Build Public..."
|
||||
yarn build:public
|
||||
|
||||
echo "Yarn Dev Minimal..."
|
||||
yarn dev:minimal
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
export PATH=$(echo $PATH | tr ':' '\n' | grep -v "/mnt/c/" | grep -v "/mnt/d/" | tr '\n' ':' | sed 's/:$//')
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
|
||||
echo "Yarn Dev Server (Debug)..."
|
||||
yarn workspace @speckle/server dev:debug
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
|
||||
|
||||
# Install NVM if not present
|
||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
|
||||
fi
|
||||
|
||||
# Load NVM
|
||||
\. "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Install Node 22
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
|
||||
# Enable corepack
|
||||
corepack enable
|
||||
|
||||
# Navigate to project
|
||||
cd /mnt/e/speckle-server
|
||||
|
||||
# Clean up previously broken native Windows builds if they exist
|
||||
rm -rf node_modules
|
||||
|
||||
# Install dependencies using yarn
|
||||
yarn install
|
||||
@@ -0,0 +1,13 @@
|
||||
$url = "http://localhost:8080"
|
||||
while ($true) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $url -UseBasicParsing
|
||||
if ($response.StatusCode -eq 200) {
|
||||
Start-Process $url
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
# continue waiting
|
||||
}
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
@@ -6475,6 +6475,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/sourcemap-codec@npm:^1.5.5":
|
||||
version: 1.5.5
|
||||
resolution: "@jridgewell/sourcemap-codec@npm:1.5.5"
|
||||
checksum: 10/5d9d207b462c11e322d71911e55e21a4e2772f71ffe8d6f1221b8eb5ae6774458c1d242f897fb0814e8714ca9a6b498abfa74dfe4f434493342902b1a48b33a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jridgewell/trace-mapping@npm:0.3.9":
|
||||
version: 0.3.9
|
||||
resolution: "@jridgewell/trace-mapping@npm:0.3.9"
|
||||
@@ -7571,6 +7578,34 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nuxt/kit@npm:^4.4.2":
|
||||
version: 4.4.2
|
||||
resolution: "@nuxt/kit@npm:4.4.2"
|
||||
dependencies:
|
||||
c12: "npm:^3.3.3"
|
||||
consola: "npm:^3.4.2"
|
||||
defu: "npm:^6.1.4"
|
||||
destr: "npm:^2.0.5"
|
||||
errx: "npm:^0.1.0"
|
||||
exsolve: "npm:^1.0.8"
|
||||
ignore: "npm:^7.0.5"
|
||||
jiti: "npm:^2.6.1"
|
||||
klona: "npm:^2.0.6"
|
||||
mlly: "npm:^1.8.1"
|
||||
ohash: "npm:^2.0.11"
|
||||
pathe: "npm:^2.0.3"
|
||||
pkg-types: "npm:^2.3.0"
|
||||
rc9: "npm:^3.0.0"
|
||||
scule: "npm:^1.3.0"
|
||||
semver: "npm:^7.7.4"
|
||||
tinyglobby: "npm:^0.2.15"
|
||||
ufo: "npm:^1.6.3"
|
||||
unctx: "npm:^2.5.0"
|
||||
untyped: "npm:^2.0.0"
|
||||
checksum: 10/2261273e58f690e99c585db728b23307b6fe53f825bf3a6b69fe8f429b6ebea91e1417af818cd709511b6cdfef79e61dd9ddd1d210d8b69e33251b74d3327471
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@nuxt/schema@npm:4.0.3":
|
||||
version: 4.0.3
|
||||
resolution: "@nuxt/schema@npm:4.0.3"
|
||||
@@ -9685,6 +9720,7 @@ __metadata:
|
||||
"@nuxt/devtools": "npm:^1.7.0"
|
||||
"@nuxt/eslint": "npm:^1.1.0"
|
||||
"@nuxt/image": "npm:^1.8.1"
|
||||
"@nuxt/kit": "npm:^4.4.2"
|
||||
"@nuxtjs/tailwindcss": "npm:^6.12.2"
|
||||
"@parcel/watcher": "npm:^2.5.1"
|
||||
"@speckle/shared": "workspace:^"
|
||||
@@ -12871,8 +12907,8 @@ __metadata:
|
||||
|
||||
"@types/react@file:./packages/frontend-2/type-augmentations/stubs/types__react::locator=root%40workspace%3A.":
|
||||
version: 0.0.0
|
||||
resolution: "@types/react@file:./packages/frontend-2/type-augmentations/stubs/types__react#./packages/frontend-2/type-augmentations/stubs/types__react::hash=3341b8&locator=root%40workspace%3A."
|
||||
checksum: 10/e8bc37abe6beea68fddb76a579cc3fac358b3603c587c1b740589544a2338a0acd98193b9b83c6cac90de61bb97435e673d4255e7698c33385d3f28dc6fc5aed
|
||||
resolution: "@types/react@file:./packages/frontend-2/type-augmentations/stubs/types__react#./packages/frontend-2/type-augmentations/stubs/types__react::hash=2fe35a&locator=root%40workspace%3A."
|
||||
checksum: 10/3fa3df65559c5308378e189df76a952025e0037b7b41031cbe6987fd383217fc1c8dd59dc58de24a9f4900c34f305bb3eeec0f7d1205edea2f58a7d8275c03e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -14607,6 +14643,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.16.0":
|
||||
version: 8.16.0
|
||||
resolution: "acorn@npm:8.16.0"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10/690c673bb4d61b38ef82795fab58526471ad7f7e67c0e40c4ff1e10ecd80ce5312554ef633c9995bfc4e6d170cef165711f9ca9e49040b62c0c66fbf2dd3df2b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"address@npm:^1.0.1":
|
||||
version: 1.2.1
|
||||
resolution: "address@npm:1.2.1"
|
||||
@@ -16040,6 +16085,31 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"c12@npm:^3.3.3":
|
||||
version: 3.3.4
|
||||
resolution: "c12@npm:3.3.4"
|
||||
dependencies:
|
||||
chokidar: "npm:^5.0.0"
|
||||
confbox: "npm:^0.2.4"
|
||||
defu: "npm:^6.1.6"
|
||||
dotenv: "npm:^17.3.1"
|
||||
exsolve: "npm:^1.0.8"
|
||||
giget: "npm:^3.2.0"
|
||||
jiti: "npm:^2.6.1"
|
||||
ohash: "npm:^2.0.11"
|
||||
pathe: "npm:^2.0.3"
|
||||
perfect-debounce: "npm:^2.1.0"
|
||||
pkg-types: "npm:^2.3.0"
|
||||
rc9: "npm:^3.0.1"
|
||||
peerDependencies:
|
||||
magicast: "*"
|
||||
peerDependenciesMeta:
|
||||
magicast:
|
||||
optional: true
|
||||
checksum: 10/2cf8fc91abd3bca463dae455209a2ca73fa6a035a22ea7506cc2e422caef31d0032fd9eb7855e58d0488eca2858335dd8f1c2604d4e74d134b9521e89fcdb1d5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"c8@npm:^10.1.3":
|
||||
version: 10.1.3
|
||||
resolution: "c8@npm:10.1.3"
|
||||
@@ -16573,6 +16643,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chokidar@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "chokidar@npm:5.0.0"
|
||||
dependencies:
|
||||
readdirp: "npm:^5.0.0"
|
||||
checksum: 10/a1c2a4ee6ee81ba6409712c295a47be055fb9de1186dfbab33c1e82f28619de962ba02fc5f9d433daaedc96c35747460d8b2079ac2907de2c95e3f7cce913113
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chownr@npm:^1.1.1":
|
||||
version: 1.1.4
|
||||
resolution: "chownr@npm:1.1.4"
|
||||
@@ -17203,6 +17282,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"confbox@npm:^0.2.4":
|
||||
version: 0.2.4
|
||||
resolution: "confbox@npm:0.2.4"
|
||||
checksum: 10/10243036f2eca8f02c85f1c8c99f492d2b690e41b5fb9c6bf91afbaca8972eb760bf9fafb7b669433c1ea0c98f12e910d4d1e73b017cd06b72150d080a2c78b6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"config-chain@npm:^1.1.13":
|
||||
version: 1.1.13
|
||||
resolution: "config-chain@npm:1.1.13"
|
||||
@@ -18319,6 +18405,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"defu@npm:^6.1.6":
|
||||
version: 6.1.7
|
||||
resolution: "defu@npm:6.1.7"
|
||||
checksum: 10/09480a5fbe6318f622f30017f9386df6ae92ed895fb1ccc61e1ff0d5016b28a321c751749fdd52c996ddd4eafc2c95b77dc0c8cc109881a231c23c7fd630deb9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"degenerator@npm:^5.0.0":
|
||||
version: 5.0.1
|
||||
resolution: "degenerator@npm:5.0.1"
|
||||
@@ -18915,6 +19008,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^17.3.1":
|
||||
version: 17.4.1
|
||||
resolution: "dotenv@npm:17.4.1"
|
||||
checksum: 10/addbff54aed55b80b5d4527ba0a221a63718835c10a2ed7d949f5caeab96995796a4c88f30263724aa71014aa4e3092a1ef551559ddef44d68c429494e13ff68
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dotenv@npm:^8.2.0":
|
||||
version: 8.6.0
|
||||
resolution: "dotenv@npm:8.6.0"
|
||||
@@ -20780,6 +20880,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"exsolve@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "exsolve@npm:1.0.8"
|
||||
checksum: 10/e7e8eac048af9f6856628a46df15529ab37428bdb5f7bc5b7824614383223de1aff60ebe85f44d9c8d4ee218d98c71df1a3e2d336f7d022a4dccd97a0651ec5b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ext-list@npm:^2.0.0":
|
||||
version: 2.2.2
|
||||
resolution: "ext-list@npm:2.2.2"
|
||||
@@ -21975,6 +22082,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"giget@npm:^3.2.0":
|
||||
version: 3.2.0
|
||||
resolution: "giget@npm:3.2.0"
|
||||
bin:
|
||||
giget: dist/cli.mjs
|
||||
checksum: 10/48c01861359da79f81268440ad432c9ed92d4755d39761ebc423ff0c8a7131c69ef7ab822f3fced10ff8275ea4e78dc32c85ee38c9d28c64bf9134ba6582a36f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"git-up@npm:^8.1.0":
|
||||
version: 8.1.1
|
||||
resolution: "git-up@npm:8.1.1"
|
||||
@@ -24846,6 +24962,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jiti@npm:^2.6.1":
|
||||
version: 2.6.1
|
||||
resolution: "jiti@npm:2.6.1"
|
||||
bin:
|
||||
jiti: lib/jiti-cli.mjs
|
||||
checksum: 10/8cd72c5fd03a0502564c3f46c49761090f6dadead21fa191b73535724f095ad86c2fa89ee6fe4bc3515337e8d406cc8fb2d37b73fa0c99a34584bac35cd4a4de
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jju@npm:~1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "jju@npm:1.4.0"
|
||||
@@ -26116,6 +26241,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^0.30.21":
|
||||
version: 0.30.21
|
||||
resolution: "magic-string@npm:0.30.21"
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
|
||||
checksum: 10/57d5691f41ed40d962d8bd300148114f53db67fadbff336207db10a99f2bdf4a1be9cac3a68ee85dba575912ee1d4402e4396408196ec2d3afd043b076156221
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magicast@npm:^0.3.3, magicast@npm:^0.3.5":
|
||||
version: 0.3.5
|
||||
resolution: "magicast@npm:0.3.5"
|
||||
@@ -27284,6 +27418,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mlly@npm:^1.8.1":
|
||||
version: 1.8.2
|
||||
resolution: "mlly@npm:1.8.2"
|
||||
dependencies:
|
||||
acorn: "npm:^8.16.0"
|
||||
pathe: "npm:^2.0.3"
|
||||
pkg-types: "npm:^1.3.1"
|
||||
ufo: "npm:^1.6.3"
|
||||
checksum: 10/e13b79edb113ac9d3ce8b5998d490cd979e907d31b562b9c6630e59623d32710cc83be1da46755ccd3143c57d50debcf98a9903d55e6e07e57910dc3369d96c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mocha-junit-reporter@npm:^2.0.2":
|
||||
version: 2.0.2
|
||||
resolution: "mocha-junit-reporter@npm:2.0.2"
|
||||
@@ -29560,6 +29706,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"perfect-debounce@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "perfect-debounce@npm:2.1.0"
|
||||
checksum: 10/1e45b92ab585fa1bfafaf01783b693b6d164a4da3b80865487f716a010d95e3d8b1131693258e342da25da671312c194cddb103c4743161f57dcf26574273fe6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pg-connection-string@npm:2.6.1, pg-connection-string@npm:^2.5.0":
|
||||
version: 2.6.1
|
||||
resolution: "pg-connection-string@npm:2.6.1"
|
||||
@@ -29671,6 +29824,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"picomatch@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "picomatch@npm:4.0.4"
|
||||
checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pidtree@npm:^0.5.0":
|
||||
version: 0.5.0
|
||||
resolution: "pidtree@npm:0.5.0"
|
||||
@@ -31383,6 +31543,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc9@npm:^3.0.0, rc9@npm:^3.0.1":
|
||||
version: 3.0.1
|
||||
resolution: "rc9@npm:3.0.1"
|
||||
dependencies:
|
||||
defu: "npm:^6.1.6"
|
||||
destr: "npm:^2.0.5"
|
||||
checksum: 10/fa74066c55bc342c4497d562a84a121a6e07af86a306a1f35a1ded077befb89f026f6f6f271d7b8207e173a66a5efcd6646e8d0cf3b9287a0fcdb5119d8feee7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rc@npm:^1.2.7":
|
||||
version: 1.2.8
|
||||
resolution: "rc@npm:1.2.8"
|
||||
@@ -31635,6 +31805,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readdirp@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "readdirp@npm:5.0.0"
|
||||
checksum: 10/a17a591b51d8b912083660df159e8bd17305dc1a9ef27c869c818bd95ff59e3a6496f97e91e724ef433e789d559d24e39496ea1698822eb5719606dc9c1a923d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"readdirp@npm:~3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "readdirp@npm:3.6.0"
|
||||
@@ -32859,6 +33036,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"semver@npm:^7.7.4":
|
||||
version: 7.7.4
|
||||
resolution: "semver@npm:7.7.4"
|
||||
bin:
|
||||
semver: bin/semver.js
|
||||
checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"send@npm:0.18.0":
|
||||
version: 0.18.0
|
||||
resolution: "send@npm:0.18.0"
|
||||
@@ -35005,6 +35191,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.15":
|
||||
version: 0.2.16
|
||||
resolution: "tinyglobby@npm:0.2.16"
|
||||
dependencies:
|
||||
fdir: "npm:^6.5.0"
|
||||
picomatch: "npm:^4.0.4"
|
||||
checksum: 10/5c2c41b572ada38449e7c86a5fe034f204a1dbba577225a761a14f29f48dc3f2fc0d81a6c56fcc67c5a742cc3aa9fb5e2ca18dbf22b610b0bc0e549b34d5a0f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinypool@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "tinypool@npm:1.0.2"
|
||||
@@ -35764,6 +35960,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ufo@npm:^1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "ufo@npm:1.6.3"
|
||||
checksum: 10/79803984f3e414567273a666183d6a50d1bec0d852100a98f55c1e393cb705e3b88033e04029dd651714e6eec99e1b00f54fdc13f32404968251a16f8898cfe5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uglify-js@npm:^3.1.4, uglify-js@npm:^3.5.1":
|
||||
version: 3.17.4
|
||||
resolution: "uglify-js@npm:3.17.4"
|
||||
@@ -35839,6 +36042,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unctx@npm:^2.5.0":
|
||||
version: 2.5.0
|
||||
resolution: "unctx@npm:2.5.0"
|
||||
dependencies:
|
||||
acorn: "npm:^8.15.0"
|
||||
estree-walker: "npm:^3.0.3"
|
||||
magic-string: "npm:^0.30.21"
|
||||
unplugin: "npm:^2.3.11"
|
||||
checksum: 10/201ca8b01f4a4476105c8eff04687973c9a8bce8327cacb77dea766e1c01caa1a31493359d08aaeb69d12d99d465b8d2dd856332f6341bf119dbb3310475cd02
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"undefsafe@npm:^2.0.5":
|
||||
version: 2.0.5
|
||||
resolution: "undefsafe@npm:2.0.5"
|
||||
@@ -36188,6 +36403,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unplugin@npm:^2.3.11":
|
||||
version: 2.3.11
|
||||
resolution: "unplugin@npm:2.3.11"
|
||||
dependencies:
|
||||
"@jridgewell/remapping": "npm:^2.3.5"
|
||||
acorn: "npm:^8.15.0"
|
||||
picomatch: "npm:^4.0.3"
|
||||
webpack-virtual-modules: "npm:^0.6.2"
|
||||
checksum: 10/7b4adbfaac8894e8491c452c0b67c612b57e103761e842d9013ebea89a4ae92a78df4ec0aa30e5e3eaaefd47dd287973d5a662271624b7346a15d9236d257f9d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unstorage@npm:^1.10.1, unstorage@npm:^1.16.1":
|
||||
version: 1.16.1
|
||||
resolution: "unstorage@npm:1.16.1"
|
||||
|
||||
+38325
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user