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

This commit is contained in:
2026-04-21 16:29:53 +07:00
parent d1871b3979
commit c99f40bb20
89 changed files with 39059 additions and 4082 deletions
+2
View File
@@ -24,3 +24,5 @@ readme.md
!.yarn/releases
!.yarn/sdks
!.yarn/versions
packages/ui-components/node_modules_old
-7
View File
@@ -90,10 +90,3 @@ packages/*/.tshy/
.nuxt
.output
.gitnexus
backend.log
packages/server/backend_crash.log
packages/server/server_log*.txt
+7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Hello world from test2
+22
View File
@@ -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
View File
@@ -1,9 +1,7 @@
compressionLevel: mixed
enableGlobalCache: false
enableMirror: false
enableScripts: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.5.0.cjs
+1 -1
View File
@@ -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 -1
View File
@@ -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.
+11
View File
@@ -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%
+7
View File
@@ -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
+110
View File
@@ -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"
}
}
-5
View File
@@ -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
})
+1
View File
@@ -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>
+220 -5
View File
@@ -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>
-56
View File
@@ -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>
-196
View File
@@ -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>
+3 -8
View File
@@ -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)
+4 -2
View File
@@ -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: {
+11
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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
+27
View File
@@ -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
+13
View File
@@ -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
}
+229 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff