Merge branch 'main' into iain/dockerfile-node18-node22
@@ -36,6 +36,9 @@ workflows:
|
||||
- test-objectsender:
|
||||
filters: *filters-allow-all
|
||||
|
||||
- test-preview-service:
|
||||
filters: *filters-allow-all
|
||||
|
||||
- test-ui-components:
|
||||
filters: *filters-allow-all
|
||||
|
||||
@@ -63,13 +66,7 @@ workflows:
|
||||
# only: /.*/
|
||||
# requires:
|
||||
# - get-version
|
||||
# - pre-commit
|
||||
# - deployment-testing-approval
|
||||
# - test-frontend-2
|
||||
# - test-viewer
|
||||
# - test-objectsender
|
||||
# - test-server
|
||||
# - test-server-no-ff
|
||||
# - docker-build-server
|
||||
# - docker-build-frontend
|
||||
# - docker-build-frontend-2
|
||||
@@ -87,13 +84,7 @@ workflows:
|
||||
only: /.*/
|
||||
requires:
|
||||
- get-version
|
||||
- pre-commit
|
||||
- deployment-testing-approval
|
||||
- test-frontend-2
|
||||
- test-viewer
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- docker-build-server
|
||||
- docker-build-frontend
|
||||
- docker-build-frontend-2
|
||||
@@ -188,6 +179,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-frontend:
|
||||
context: *docker-hub-context
|
||||
@@ -202,6 +194,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-frontend-2:
|
||||
context: *docker-hub-context
|
||||
@@ -216,6 +209,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-webhooks:
|
||||
context: *docker-hub-context
|
||||
@@ -230,6 +224,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-file-imports:
|
||||
context: *docker-hub-context
|
||||
@@ -244,6 +239,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-previews:
|
||||
context: *docker-hub-context
|
||||
@@ -258,6 +254,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-test-container:
|
||||
context: *docker-hub-context
|
||||
@@ -272,6 +269,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-monitor-container:
|
||||
context: *docker-hub-context
|
||||
@@ -286,6 +284,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- docker-publish-docker-compose-ingress:
|
||||
context: *docker-hub-context
|
||||
@@ -300,6 +299,7 @@ workflows:
|
||||
- test-objectsender
|
||||
- test-server
|
||||
- test-server-no-ff
|
||||
- test-preview-service
|
||||
|
||||
- publish-helm-chart:
|
||||
filters: &filters-publish
|
||||
@@ -343,6 +343,7 @@ workflows:
|
||||
- test-frontend-2
|
||||
- test-viewer
|
||||
- test-objectsender
|
||||
- test-preview-service
|
||||
|
||||
- publish-viewer-sandbox-cloudflare-pages:
|
||||
filters: *filters-publish
|
||||
@@ -397,7 +398,7 @@ jobs:
|
||||
type: string
|
||||
docker:
|
||||
- image: speckle/pre-commit-runner:latest
|
||||
resource_class: large
|
||||
resource_class: xlarge
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
@@ -416,9 +417,6 @@ jobs:
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-{{ checksum "yarn.lock" }}
|
||||
@@ -476,10 +474,6 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -553,6 +547,7 @@ jobs:
|
||||
AUTOMATE_ENCRYPTION_KEYS_PATH: 'test/assets/automate/encryptionKeys.json'
|
||||
FF_AUTOMATE_MODULE_ENABLED: 'false' # Disable all FFs
|
||||
FF_WORKSPACES_MODULE_ENABLED: 'false'
|
||||
FF_WORKSPACES_SSO_ENABLED: 'false'
|
||||
FF_MULTIPLE_EMAILS_MODULE_ENABLED: 'false'
|
||||
FF_GENDOAI_MODULE_ENABLED: 'false'
|
||||
|
||||
@@ -570,10 +565,6 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -591,8 +582,7 @@ jobs:
|
||||
working_directory: 'packages/frontend-2'
|
||||
|
||||
test-viewer:
|
||||
docker: &docker-node-browsers-image
|
||||
- image: cimg/node:22.6.0-browsers
|
||||
docker: *docker-node-browsers-image
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
@@ -604,10 +594,6 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -629,10 +615,16 @@ jobs:
|
||||
command: yarn test
|
||||
working_directory: 'packages/viewer'
|
||||
|
||||
test-objectsender:
|
||||
docker: &docker-node-browsers-image
|
||||
test-preview-service:
|
||||
docker:
|
||||
- image: cimg/node:22.6.0-browsers
|
||||
- image: cimg/postgres:14.11
|
||||
environment:
|
||||
POSTGRES_DB: preview_service_test
|
||||
POSTGRES_PASSWORD: preview_service_test
|
||||
POSTGRES_USER: preview_service_test
|
||||
resource_class: large
|
||||
environment: {}
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
@@ -643,8 +635,45 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
paths:
|
||||
- .yarn/cache
|
||||
- .yarn/unplugged
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
name: Build public packages
|
||||
command: yarn build:public
|
||||
|
||||
- run:
|
||||
name: Lint everything
|
||||
command: yarn lint:ci
|
||||
working_directory: 'packages/preview-service'
|
||||
|
||||
- run:
|
||||
name: Copy .env.example to .env
|
||||
command: |
|
||||
#!/usr/bin/env bash
|
||||
cp packages/preview-service/.env.example packages/preview-service/.env
|
||||
sed -i~ '/^PG_CONNECTION_STRING=/s/=.*/="postgres:\/\/preview_service_test:preview_service_test@127.0.0.1:5432\/preview_service_test"/' packages/preview-service/.env
|
||||
|
||||
- run:
|
||||
name: Run tests
|
||||
command: yarn test
|
||||
working_directory: 'packages/preview-service'
|
||||
|
||||
test-objectsender:
|
||||
docker: *docker-node-browsers-image
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore Yarn Package Cache
|
||||
keys:
|
||||
- yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
@@ -677,10 +706,6 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -730,10 +755,6 @@ jobs:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -757,7 +778,7 @@ jobs:
|
||||
# because it requires node_modules
|
||||
# therefore this scanning has to be triggered via the cli
|
||||
docker: *docker-node-image
|
||||
resource_class: small
|
||||
resource_class: medium
|
||||
working_directory: *work-dir
|
||||
steps:
|
||||
- checkout
|
||||
@@ -768,9 +789,6 @@ jobs:
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -922,7 +940,7 @@ jobs:
|
||||
|
||||
docker-build-frontend-2:
|
||||
<<: *build-job
|
||||
resource_class: large
|
||||
resource_class: xlarge
|
||||
environment:
|
||||
SPECKLE_SERVER_PACKAGE: frontend-2
|
||||
|
||||
@@ -1038,9 +1056,6 @@ jobs:
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-{{ checksum "yarn.lock" }}
|
||||
@@ -1111,9 +1126,6 @@ jobs:
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
@@ -1154,9 +1166,6 @@ jobs:
|
||||
- run:
|
||||
name: Install Dependencies
|
||||
command: yarn
|
||||
- run:
|
||||
name: Install Dependencies v2 (.node files missing bug)
|
||||
command: yarn
|
||||
- save_cache:
|
||||
name: Save Yarn Package Cache
|
||||
key: yarn-packages-server-{{ checksum "yarn.lock" }}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
secret:
|
||||
ignored-paths:
|
||||
- 'packages/server/modules/emails/tests'
|
||||
ignored-matches:
|
||||
ignored_paths:
|
||||
- 'packages/server/modules/emails/tests/*'
|
||||
- 'packages/server/modules/core/tests/*'
|
||||
ignored_matches:
|
||||
- match: acd87c5a50b56df91a795e999812a3a4
|
||||
name: 'packages/frontend/src/bootstrapper.ts - mixpanel token'
|
||||
- match: c7bf45ffe02afaae52c8e37cdb1ae33165370be3b44a5da43e8cba43c7da5f33
|
||||
@@ -10,4 +11,23 @@ secret:
|
||||
name: '.circleci/deployment/manifests/speckle-server.secret.yaml - test session_secret'
|
||||
- match: 9bf360c5ce31170e8e3cb30e275b2c00224dd97b93282491c60fb1665fac3845
|
||||
name: local test license
|
||||
- match: 7a4ab6f7bfbcc0a37aa3a0fb00fd5b6edd1d524f393a6054e242eb28f5c06be5
|
||||
name: 'packages/server/modules/core/tests/graph.spec.js - test secret'
|
||||
- match: be603148062b367f828a58bdd695149d24f55f7c7f2e2c0bc31abd147cd07e86
|
||||
name: packages/server/modules/webhooks/tests/cleanup.spec.ts - test password
|
||||
- match: d1c44da2d7d52afaf219ff9789df7c04a79be80977336d7c87652db736b07538
|
||||
name: packages/server/.env-example - test password for keycloak
|
||||
- match: 05b116fa36d25a831d96d5b4ecd45b962ebf9345dcf81ac0950c4adb49e10183
|
||||
name: packages/server/modules/serverinvites/tests/invites.spec.ts - test password
|
||||
- match: 22ef4aa9beab564872bb1f15ff7592894ad445a68d6b03364f890cc5c3866b5d
|
||||
name: packages/server/modules/core/tests/users.spec.js - test password
|
||||
- match: 05b116fa36d25a831d96d5b4ecd45b962ebf9345dcf81ac0950c4adb49e10183
|
||||
name: packages/server/modules/core/tests/users.spec.js - test password
|
||||
- match: d1c44da2d7d52afaf219ff9789df7c04a79be80977336d7c87652db736b07538
|
||||
name: setup/keycloak/speckle-realm.json - secret for dev keycloak
|
||||
- match: b92d3b9844a823512dd1831c1eea5d9810c154027e07a36f007232fc26e9f70c
|
||||
name: setup/keycloak/speckle-realm.json - secret for dev keycloak
|
||||
- match: 2e1b3675a4049cd39fe6db081735f747730969071528270800f00fa98720d198
|
||||
name: setup/keycloak/speckle-realm.json - algorithm name
|
||||
|
||||
version: 2
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
name: Preview service acceptance test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request: # Pushing a new commit to the HEAD ref of a pull request will trigger the “synchronize” event
|
||||
paths:
|
||||
- .yarnrc.yml .
|
||||
- .yarn
|
||||
- package.json
|
||||
- '.github/workflows/preview-service-acceptance.yml'
|
||||
- 'packages/frontend-2/type-augmentations/stubs/**/*'
|
||||
- 'packages/preview-service/**/*'
|
||||
- 'packages/viewer/**/*'
|
||||
- 'packages/objectloader/**/*'
|
||||
- 'packages/shared/**/*'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/speckle-preview-service
|
||||
OUTPUT_FILE_PATH: 'preview-service-output/${{ github.sha }}.png'
|
||||
|
||||
jobs:
|
||||
build-preview-service:
|
||||
name: Build Preview Service
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
packages: write # publishing container to GitHub registry
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5.5.1
|
||||
with:
|
||||
tags: type=sha,format=long
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
- name: Build and load preview-service Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/preview-service/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
preview-service-acceptance:
|
||||
name: Preview Service Acceptance test
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-preview-service
|
||||
|
||||
permissions:
|
||||
contents: write # to update the screenshot saved in the branch. This is a HACK as GitHub API does not yet support uploading attachments to a comment.
|
||||
pull-requests: write # to write a comment on the PR
|
||||
packages: read # to download the preview-service image
|
||||
|
||||
services:
|
||||
postgres:
|
||||
# Docker Hub image
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_DB: preview_service_test
|
||||
POSTGRES_PASSWORD: preview_service_test
|
||||
POSTGRES_USER: preview_service_test
|
||||
# Set health checks to wait until postgres has started
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
preview-service:
|
||||
image: ${{ needs.build-preview-service.outputs.tags }}
|
||||
env:
|
||||
# note that the host is the postgres service name
|
||||
PG_CONNECTION_STRING: postgres://preview_service_test:preview_service_test@postgres:5432/preview_service_test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
- name: Install dependencies
|
||||
working-directory: packages/preview-service
|
||||
run: yarn install
|
||||
|
||||
- name: Run the acceptance test
|
||||
working-directory: packages/preview-service
|
||||
run: yarn test:acceptance
|
||||
env:
|
||||
NODE_ENV: test
|
||||
TEST_DB: preview_service_test
|
||||
# note that the host is localhost, but the port is the port mapped to the postgres service
|
||||
PG_CONNECTION_STRING: postgres://preview_service_test:preview_service_test@localhost:5432/preview_service_test
|
||||
OUTPUT_FILE_PATH: ${{ env.OUTPUT_FILE_PATH }}
|
||||
S3_BUCKET: ${{ vars.S3_BUCKET }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '📸 Preview service has generated <a href="${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/${{ env.OUTPUT_FILE_PATH}}">an image.</a>'
|
||||
})
|
||||
@@ -4,4 +4,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.3.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.5.0.cjs
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data/
|
||||
- ./setup/db/10-docker_postgres_init.sql:/docker-entrypoint-initdb.d/10-docker_postgres_init.sql
|
||||
- ./setup/db/11-docker_postgres_keycloack_init.sql:/docker-entrypoint-initdb.d/11-docker_postgres_keycloack_init.sql
|
||||
ports:
|
||||
- '127.0.0.1:5432:5432'
|
||||
|
||||
@@ -35,6 +36,40 @@ services:
|
||||
- '127.0.0.1:9000:9000'
|
||||
- '127.0.0.1:9001:9001'
|
||||
|
||||
# Local OIDC provider for testing
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:25.0
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
||||
KC_DB_USERNAME: speckle
|
||||
KC_DB_PASSWORD: speckle
|
||||
|
||||
KC_HOSTNAME: 127.0.0.1
|
||||
KC_HOSTNAME_PORT: 9000
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
|
||||
KC_LOG_LEVEL: info
|
||||
KC_METRICS_ENABLED: true
|
||||
KC_HEALTH_ENABLED: true
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
ports:
|
||||
- 8443:8443
|
||||
- 9010:9000
|
||||
- 8090:8080
|
||||
command: start-dev --import-realm
|
||||
volumes:
|
||||
- ./setup/keycloak:/opt/keycloak/data/import
|
||||
# user: root
|
||||
# command: export --dir /opt/keycloak/backup --realm speckle
|
||||
# volumes:
|
||||
# - ./keycloak-backup:/opt/keycloak/backup
|
||||
|
||||
# Local email server for email troubleshooting
|
||||
maildev:
|
||||
restart: always
|
||||
image: maildev/maildev
|
||||
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- /nodejs/bin/node
|
||||
- -e
|
||||
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
|
||||
interval: 10s
|
||||
@@ -86,7 +86,8 @@ services:
|
||||
mem_limit: '3000m'
|
||||
memswap_limit: '3000m'
|
||||
environment:
|
||||
HOST: '127.0.0.1'
|
||||
HOST: '127.0.0.1' # Only accept connections from localhost, as preview service does not need to be exposed outside the container.
|
||||
METRICS_HOST: '127.0.0.1' # Amend if you want to expose Prometheus metrics outside of the container
|
||||
LOG_LEVEL: 'info'
|
||||
PG_CONNECTION_STRING: 'postgres://speckle:speckle@postgres/speckle'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"packageManager": "yarn@4.3.0",
|
||||
"packageManager": "yarn@4.5.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -78,7 +78,6 @@
|
||||
"core-js-compat/semver": "^7.5.4",
|
||||
"eslint": "^9.4.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"graphql": "^15.3.0",
|
||||
"levelup/bl": ">=1.2.3",
|
||||
"levelup/semver": ">=5.7.2",
|
||||
"mocha/serialize-javascript": ">=6.0.2",
|
||||
|
||||
@@ -24,6 +24,7 @@ export type Scalars = {
|
||||
|
||||
export type ActiveUserMutations = {
|
||||
__typename?: 'ActiveUserMutations';
|
||||
emailMutations: UserEmailMutations;
|
||||
/** Mark onboarding as complete */
|
||||
finishOnboarding: Scalars['Boolean']['output'];
|
||||
/** Edit a user's profile */
|
||||
@@ -55,6 +56,11 @@ export type ActivityCollection = {
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AddDomainToWorkspaceInput = {
|
||||
domain: Scalars['String']['input'];
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AdminInviteList = {
|
||||
__typename?: 'AdminInviteList';
|
||||
cursor?: Maybe<Scalars['String']['output']>;
|
||||
@@ -805,6 +811,10 @@ export type CreateModelInput = {
|
||||
projectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type CreateUserEmailInput = {
|
||||
email: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateVersionInput = {
|
||||
message?: InputMaybe<Scalars['String']['input']>;
|
||||
modelId: Scalars['String']['input'];
|
||||
@@ -815,11 +825,21 @@ export type CreateVersionInput = {
|
||||
totalChildrenCount?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export enum Currency {
|
||||
Eur = 'EUR',
|
||||
Gbp = 'GBP',
|
||||
Usd = 'USD'
|
||||
}
|
||||
|
||||
export type DeleteModelInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
projectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeleteUserEmailInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeleteVersionsInput = {
|
||||
versionIds: Array<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -834,11 +854,24 @@ export type DiscoverableStreamsSortingInput = {
|
||||
type: DiscoverableStreamsSortType;
|
||||
};
|
||||
|
||||
export type DiscoverableWorkspace = {
|
||||
__typename?: 'DiscoverableWorkspace';
|
||||
defaultLogoIndex: Scalars['Int']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
logo?: Maybe<Scalars['String']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EditCommentInput = {
|
||||
commentId: Scalars['String']['input'];
|
||||
content: CommentContentInput;
|
||||
};
|
||||
|
||||
export type EmailVerificationRequestInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type FileUpload = {
|
||||
__typename?: 'FileUpload';
|
||||
branchName: Scalars['String']['output'];
|
||||
@@ -903,6 +936,10 @@ export type GendoAiRenderInput = {
|
||||
versionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type JoinWorkspaceInput = {
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type LegacyCommentViewerData = {
|
||||
__typename?: 'LegacyCommentViewerData';
|
||||
/**
|
||||
@@ -958,6 +995,7 @@ export type LimitedUser = {
|
||||
*/
|
||||
totalOwnedStreamsFavorites: Scalars['Int']['output'];
|
||||
verified?: Maybe<Scalars['Boolean']['output']>;
|
||||
workspaceDomainPolicyCompliant?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1005,6 +1043,15 @@ export type LimitedUserTimelineArgs = {
|
||||
limit?: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Limited user type, for showing public info about a user
|
||||
* to another user
|
||||
*/
|
||||
export type LimitedUserWorkspaceDomainPolicyCompliantArgs = {
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type MarkReceivedVersionInput = {
|
||||
message?: InputMaybe<Scalars['String']['input']>;
|
||||
projectId: Scalars['String']['input'];
|
||||
@@ -1697,6 +1744,11 @@ export type PendingStreamCollaborator = {
|
||||
|
||||
export type PendingWorkspaceCollaborator = {
|
||||
__typename?: 'PendingWorkspaceCollaborator';
|
||||
/**
|
||||
* E-mail address if target is unregistered or primary e-mail of target registered user
|
||||
* if token was specified to retrieve this invite
|
||||
*/
|
||||
email?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
inviteId: Scalars['String']['output'];
|
||||
invitedBy: LimitedUser;
|
||||
@@ -1704,14 +1756,22 @@ export type PendingWorkspaceCollaborator = {
|
||||
role: Scalars['String']['output'];
|
||||
/** E-mail address or name of the invited user */
|
||||
title: Scalars['String']['output'];
|
||||
/** Only available if the active user is the pending workspace collaborator */
|
||||
/**
|
||||
* Only available if the active user is the pending workspace collaborator or if it was already
|
||||
* specified when retrieving this invite
|
||||
*/
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
/** Set only if user is registered */
|
||||
user?: Maybe<LimitedUser>;
|
||||
workspaceId: Scalars['String']['output'];
|
||||
workspaceName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PendingWorkspaceCollaboratorsFilter = {
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Project = {
|
||||
__typename?: 'Project';
|
||||
allowPublicComments: Scalars['Boolean']['output'];
|
||||
@@ -1764,6 +1824,7 @@ export type Project = {
|
||||
visibility: ProjectVisibility;
|
||||
webhooks: WebhookCollection;
|
||||
workspace?: Maybe<Workspace>;
|
||||
workspaceId?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1799,7 +1860,7 @@ export type ProjectCommentArgs = {
|
||||
export type ProjectCommentThreadsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<ProjectCommentsFilter>;
|
||||
limit?: Scalars['Int']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2064,6 +2125,11 @@ export type ProjectInviteMutations = {
|
||||
cancel: Project;
|
||||
/** Invite a new or registered user to be a project collaborator. Can only be invoked by a project owner. */
|
||||
create: Project;
|
||||
/**
|
||||
* Create invite(-s) for a project in a workspace. Unlike the base create() mutation, this allows
|
||||
* configuring the workspace role.
|
||||
*/
|
||||
createForWorkspace: Project;
|
||||
/** Accept or decline a project invite */
|
||||
use: Scalars['Boolean']['output'];
|
||||
};
|
||||
@@ -2087,6 +2153,12 @@ export type ProjectInviteMutationsCreateArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type ProjectInviteMutationsCreateForWorkspaceArgs = {
|
||||
inputs: Array<WorkspaceProjectInviteCreateInput>;
|
||||
projectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type ProjectInviteMutationsUseArgs = {
|
||||
input: ProjectInviteUseInput;
|
||||
};
|
||||
@@ -2223,6 +2295,12 @@ export enum ProjectPendingVersionsUpdatedMessageType {
|
||||
Updated = 'UPDATED'
|
||||
}
|
||||
|
||||
export type ProjectRole = {
|
||||
__typename?: 'ProjectRole';
|
||||
project: Project;
|
||||
role: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ProjectTestAutomationCreateInput = {
|
||||
functionId: Scalars['String']['input'];
|
||||
modelId: Scalars['String']['input'];
|
||||
@@ -2410,12 +2488,15 @@ export type Query = {
|
||||
* The query looks for matches in name & email
|
||||
*/
|
||||
userSearch: UserSearchResultCollection;
|
||||
/** Validates the slug, to make sure it contains only valid characters and its not taken. */
|
||||
validateWorkspaceSlug: Scalars['Boolean']['output'];
|
||||
workspace: Workspace;
|
||||
/**
|
||||
* Look for an invitation to a workspace, for the current user (authed or not). If token
|
||||
* isn't specified, the server will look for any valid invite.
|
||||
* Look for an invitation to a workspace, for the current user (authed or not).
|
||||
*
|
||||
* If token is specified, it will return the corresponding invite even if it belongs to a different user.
|
||||
*
|
||||
* Either token or workspaceId must be specified, or both
|
||||
*/
|
||||
workspaceInvite?: Maybe<PendingWorkspaceCollaborator>;
|
||||
};
|
||||
@@ -2498,7 +2579,7 @@ export type QueryProjectInviteArgs = {
|
||||
|
||||
|
||||
export type QueryServerInviteByTokenArgs = {
|
||||
token: Scalars['String']['input'];
|
||||
token?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -2544,6 +2625,11 @@ export type QueryUserSearchArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryValidateWorkspaceSlugArgs = {
|
||||
slug: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryWorkspaceArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
@@ -2551,7 +2637,7 @@ export type QueryWorkspaceArgs = {
|
||||
|
||||
export type QueryWorkspaceInviteArgs = {
|
||||
token?: InputMaybe<Scalars['String']['input']>;
|
||||
workspaceId: Scalars['String']['input'];
|
||||
workspaceId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
/** Deprecated: Used by old stream-based mutations */
|
||||
@@ -2630,6 +2716,14 @@ export type ServerAutomateInfo = {
|
||||
availableFunctionTemplates: Array<AutomateFunctionTemplate>;
|
||||
};
|
||||
|
||||
/** Server configuration. */
|
||||
export type ServerConfiguration = {
|
||||
__typename?: 'ServerConfiguration';
|
||||
blobSizeLimitBytes: Scalars['Int']['output'];
|
||||
objectMultipartUploadSizeLimitBytes: Scalars['Int']['output'];
|
||||
objectSizeLimitBytes: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** Information about this server. */
|
||||
export type ServerInfo = {
|
||||
__typename?: 'ServerInfo';
|
||||
@@ -2639,9 +2733,16 @@ export type ServerInfo = {
|
||||
automate: ServerAutomateInfo;
|
||||
/** Base URL of Speckle Automate, if set */
|
||||
automateUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use the ServerInfo{configuration{blobSizeLimitBytes}} field instead. */
|
||||
blobSizeLimitBytes: Scalars['Int']['output'];
|
||||
canonicalUrl?: Maybe<Scalars['String']['output']>;
|
||||
company?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Configuration values that are specific to this server.
|
||||
* These are read-only and can only be adjusted during server setup.
|
||||
* Please contact your server administrator if you wish to suggest a change to these values.
|
||||
*/
|
||||
configuration: ServerConfiguration;
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
/** Whether or not to show messaging about FE2 (banners etc.) */
|
||||
enableNewWebUiMessaging?: Maybe<Scalars['Boolean']['output']>;
|
||||
@@ -2734,6 +2835,10 @@ export type ServerWorkspacesInfo = {
|
||||
workspacesEnabled: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type SetPrimaryUserEmailInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SmartTextEditorValue = {
|
||||
__typename?: 'SmartTextEditorValue';
|
||||
/** File attachments, if any */
|
||||
@@ -3307,8 +3412,11 @@ export type User = {
|
||||
/** Returns the apps you have created. */
|
||||
createdApps?: Maybe<Array<ServerApp>>;
|
||||
createdAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
/** Get discoverable workspaces with verified domains that match the active user's */
|
||||
discoverableWorkspaces: Array<DiscoverableWorkspace>;
|
||||
/** Only returned if API user is the user being requested or an admin */
|
||||
email?: Maybe<Scalars['String']['output']>;
|
||||
emails: Array<UserEmail>;
|
||||
/**
|
||||
* All the streams that a active user has favorited.
|
||||
* Note: You can't use this to retrieve another user's favorite streams.
|
||||
@@ -3466,6 +3574,43 @@ export type UserDeleteInput = {
|
||||
email: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type UserEmail = {
|
||||
__typename?: 'UserEmail';
|
||||
email: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
primary: Scalars['Boolean']['output'];
|
||||
userId: Scalars['ID']['output'];
|
||||
verified: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type UserEmailMutations = {
|
||||
__typename?: 'UserEmailMutations';
|
||||
create: User;
|
||||
delete: User;
|
||||
requestNewEmailVerification?: Maybe<Scalars['Boolean']['output']>;
|
||||
setPrimary: User;
|
||||
};
|
||||
|
||||
|
||||
export type UserEmailMutationsCreateArgs = {
|
||||
input: CreateUserEmailInput;
|
||||
};
|
||||
|
||||
|
||||
export type UserEmailMutationsDeleteArgs = {
|
||||
input: DeleteUserEmailInput;
|
||||
};
|
||||
|
||||
|
||||
export type UserEmailMutationsRequestNewEmailVerificationArgs = {
|
||||
input: EmailVerificationRequestInput;
|
||||
};
|
||||
|
||||
|
||||
export type UserEmailMutationsSetPrimaryArgs = {
|
||||
input: SetPrimaryUserEmailInput;
|
||||
};
|
||||
|
||||
export type UserProjectsFilter = {
|
||||
/** Only include projects where user has the specified roles */
|
||||
onlyWithRoles?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -3686,7 +3831,7 @@ export type WebhookCreateInput = {
|
||||
enabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
secret?: InputMaybe<Scalars['String']['input']>;
|
||||
streamId: Scalars['String']['input'];
|
||||
triggers: Array<InputMaybe<Scalars['String']['input']>>;
|
||||
triggers: Array<Scalars['String']['input']>;
|
||||
url: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
@@ -3718,16 +3863,28 @@ export type WebhookUpdateInput = {
|
||||
id: Scalars['String']['input'];
|
||||
secret?: InputMaybe<Scalars['String']['input']>;
|
||||
streamId: Scalars['String']['input'];
|
||||
triggers?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
|
||||
triggers?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
url?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
__typename?: 'Workspace';
|
||||
/** Billing data for Workspaces beta */
|
||||
billing?: Maybe<WorkspaceBilling>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Selected fallback when `logo` not set */
|
||||
defaultLogoIndex: Scalars['Int']['output'];
|
||||
/** The default role workspace members will receive for workspace projects. */
|
||||
defaultProjectRole: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
/** Enable/Disable discovery of the workspace */
|
||||
discoverabilityEnabled: Scalars['Boolean']['output'];
|
||||
/** Enable/Disable restriction to invite users to workspace as Guests only */
|
||||
domainBasedMembershipProtectionEnabled: Scalars['Boolean']['output'];
|
||||
/** Verified workspace domains */
|
||||
domains?: Maybe<Array<WorkspaceDomain>>;
|
||||
id: Scalars['ID']['output'];
|
||||
/** Only available to workspace owners */
|
||||
/** Only available to workspace owners/members */
|
||||
invitedTeam?: Maybe<Array<PendingWorkspaceCollaborator>>;
|
||||
/** Logo image as base64-encoded string */
|
||||
logo?: Maybe<Scalars['String']['output']>;
|
||||
@@ -3735,24 +3892,52 @@ export type Workspace = {
|
||||
projects: ProjectCollection;
|
||||
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
|
||||
role?: Maybe<Scalars['String']['output']>;
|
||||
team: Array<WorkspaceCollaborator>;
|
||||
slug: Scalars['String']['output'];
|
||||
team: WorkspaceCollaboratorCollection;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInvitedTeamArgs = {
|
||||
filter?: InputMaybe<PendingWorkspaceCollaboratorsFilter>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectsArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceProjectsFilter>;
|
||||
limit?: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceTeamArgs = {
|
||||
cursor?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<WorkspaceTeamFilter>;
|
||||
limit?: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type WorkspaceBilling = {
|
||||
__typename?: 'WorkspaceBilling';
|
||||
cost: WorkspaceCost;
|
||||
versionsCount: WorkspaceVersionsCount;
|
||||
};
|
||||
|
||||
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
|
||||
export type WorkspaceCollaborator = {
|
||||
__typename?: 'WorkspaceCollaborator';
|
||||
id: Scalars['ID']['output'];
|
||||
projectRoles: Array<ProjectRole>;
|
||||
role: Scalars['String']['output'];
|
||||
user: LimitedUser;
|
||||
};
|
||||
|
||||
export type WorkspaceCollaboratorCollection = {
|
||||
__typename?: 'WorkspaceCollaboratorCollection';
|
||||
cursor?: Maybe<Scalars['String']['output']>;
|
||||
items: Array<WorkspaceCollaborator>;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceCollection = {
|
||||
__typename?: 'WorkspaceCollection';
|
||||
cursor?: Maybe<Scalars['String']['output']>;
|
||||
@@ -3760,9 +3945,51 @@ export type WorkspaceCollection = {
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceCost = {
|
||||
__typename?: 'WorkspaceCost';
|
||||
/** Currency of the price */
|
||||
currency: Currency;
|
||||
/** Discount applied to the total */
|
||||
discount?: Maybe<WorkspaceCostDiscount>;
|
||||
items: Array<WorkspaceCostItem>;
|
||||
/** Estimated cost of the workspace with no discount applied */
|
||||
subTotal: Scalars['Float']['output'];
|
||||
/** Total cost with discount applied */
|
||||
total: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceCostDiscount = {
|
||||
__typename?: 'WorkspaceCostDiscount';
|
||||
amount: Scalars['Float']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceCostItem = {
|
||||
__typename?: 'WorkspaceCostItem';
|
||||
cost: Scalars['Float']['output'];
|
||||
count: Scalars['Int']['output'];
|
||||
label: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceCreateInput = {
|
||||
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Logo image as base64-encoded string */
|
||||
logo?: InputMaybe<Scalars['String']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type WorkspaceDomain = {
|
||||
__typename?: 'WorkspaceDomain';
|
||||
domain: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type WorkspaceDomainDeleteInput = {
|
||||
id: Scalars['ID']['input'];
|
||||
workspaceId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type WorkspaceInviteCreateInput = {
|
||||
@@ -3770,6 +3997,8 @@ export type WorkspaceInviteCreateInput = {
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the member role, if not specified */
|
||||
role?: InputMaybe<WorkspaceRole>;
|
||||
/** Defaults to User, if not specified */
|
||||
serverRole?: InputMaybe<ServerRole>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -3779,6 +4008,7 @@ export type WorkspaceInviteMutations = {
|
||||
batchCreate: Workspace;
|
||||
cancel: Workspace;
|
||||
create: Workspace;
|
||||
resend: Scalars['Boolean']['output'];
|
||||
use: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
@@ -3801,25 +4031,50 @@ export type WorkspaceInviteMutationsCreateArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInviteMutationsResendArgs = {
|
||||
input: WorkspaceInviteResendInput;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceInviteMutationsUseArgs = {
|
||||
input: WorkspaceInviteUseInput;
|
||||
};
|
||||
|
||||
export type WorkspaceInviteResendInput = {
|
||||
inviteId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type WorkspaceInviteUseInput = {
|
||||
accept: Scalars['Boolean']['input'];
|
||||
/**
|
||||
* If invite is attached to an unregistered email, the invite can only be used if this is set to true.
|
||||
* Upon accepting such an invite, the unregistered email will be added to the user's account as well.
|
||||
*/
|
||||
addNewEmail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
token: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type WorkspaceMutations = {
|
||||
__typename?: 'WorkspaceMutations';
|
||||
addDomain: Workspace;
|
||||
create: Workspace;
|
||||
delete: Scalars['Boolean']['output'];
|
||||
deleteDomain: Workspace;
|
||||
invites: WorkspaceInviteMutations;
|
||||
join: Workspace;
|
||||
leave: Scalars['Boolean']['output'];
|
||||
projects: WorkspaceProjectMutations;
|
||||
update: Workspace;
|
||||
updateRole: Workspace;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsAddDomainArgs = {
|
||||
input: AddDomainToWorkspaceInput;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsCreateArgs = {
|
||||
input: WorkspaceCreateInput;
|
||||
};
|
||||
@@ -3830,6 +4085,21 @@ export type WorkspaceMutationsDeleteArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsDeleteDomainArgs = {
|
||||
input: WorkspaceDomainDeleteInput;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsJoinArgs = {
|
||||
input: JoinWorkspaceInput;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsLeaveArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceMutationsUpdateArgs = {
|
||||
input: WorkspaceUpdateInput;
|
||||
};
|
||||
@@ -3839,6 +4109,36 @@ export type WorkspaceMutationsUpdateRoleArgs = {
|
||||
input: WorkspaceRoleUpdateInput;
|
||||
};
|
||||
|
||||
export type WorkspaceProjectInviteCreateInput = {
|
||||
/** Either this or userId must be filled */
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Defaults to the contributor role, if not specified */
|
||||
role?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Can only be specified if guest mode is on or if the user is an admin */
|
||||
serverRole?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Either this or email must be filled */
|
||||
userId?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Only taken into account, if project belongs to a workspace. Defaults to guest access. */
|
||||
workspaceRole?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type WorkspaceProjectMutations = {
|
||||
__typename?: 'WorkspaceProjectMutations';
|
||||
moveToWorkspace: Project;
|
||||
updateRole: Project;
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectMutationsMoveToWorkspaceArgs = {
|
||||
projectId: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkspaceProjectMutationsUpdateRoleArgs = {
|
||||
input: ProjectUpdateRoleInput;
|
||||
};
|
||||
|
||||
export type WorkspaceProjectsFilter = {
|
||||
/** Filter out projects by name */
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -3862,12 +4162,32 @@ export type WorkspaceRoleUpdateInput = {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type WorkspaceTeamFilter = {
|
||||
/** Limit team members to provided role */
|
||||
role?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Search for team members by name or email */
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type WorkspaceUpdateInput = {
|
||||
defaultLogoIndex?: InputMaybe<Scalars['Int']['input']>;
|
||||
defaultProjectRole?: InputMaybe<Scalars['String']['input']>;
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
discoverabilityEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
domainBasedMembershipProtectionEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
id: Scalars['String']['input'];
|
||||
/** Logo image as base64-encoded string */
|
||||
logo?: InputMaybe<Scalars['String']['input']>;
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
slug?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type WorkspaceVersionsCount = {
|
||||
__typename?: 'WorkspaceVersionsCount';
|
||||
/** Total number of versions of all projects in the workspace */
|
||||
current: Scalars['Int']['output'];
|
||||
/** Maximum number of version of all projects in the workspace with no additional cost */
|
||||
max: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AcccountTestQueryQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -10,7 +10,9 @@ module.exports = require('knex')({
|
||||
},
|
||||
pool: {
|
||||
min: 0,
|
||||
max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1
|
||||
max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS_FILE_IMPORT_SERVICE) || 1,
|
||||
acquireTimeoutMillis: 16000, //allows for 3x creation attempts plus idle time between attempts
|
||||
createTimeoutMillis: 5000
|
||||
}
|
||||
// migrations are in managed in the server package
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ useHead({
|
||||
},
|
||||
bodyAttrs: {
|
||||
class:
|
||||
'simple-scrollbar overflow-y-scroll has-[.viewer]:overflow-auto bg-foundation-page text-foreground has-[.viewer-transparent]:!bg-transparent'
|
||||
'bg-foundation-page text-foreground has-[.viewer-transparent]:!bg-transparent'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 223 KiB |
@@ -35,7 +35,7 @@ const config: CodegenConfig = {
|
||||
fragmentMasking: false,
|
||||
dedupeFragments: true
|
||||
},
|
||||
plugins: []
|
||||
plugins: ['./tools/gqlCacheHelpersCodegenPlugin.js']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class="mx-auto w-full"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col items-center gap-y-2 pb-4">
|
||||
<div v-if="!workspaceInvite" class="flex flex-col items-center gap-y-2 pb-4">
|
||||
<h1 class="text-heading-xl text-center inline-block">
|
||||
{{ title }}
|
||||
</h1>
|
||||
@@ -14,11 +14,13 @@
|
||||
{{ subtitle }}
|
||||
</h2>
|
||||
</div>
|
||||
<AuthWorkspaceInviteHeader v-else :invite="workspaceInvite" />
|
||||
<AuthThirdPartyLoginBlock
|
||||
v-if="hasThirdPartyStrategies && serverInfo"
|
||||
:server-info="serverInfo"
|
||||
:challenge="challenge"
|
||||
:app-id="appId"
|
||||
:newsletter-consent="false"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
@@ -27,8 +29,12 @@
|
||||
>
|
||||
{{ hasThirdPartyStrategies ? 'Or login with your email' : '' }}
|
||||
</div>
|
||||
<AuthLoginWithEmailBlock v-if="hasLocalStrategy" :challenge="challenge" />
|
||||
<div class="text-center text-body-sm">
|
||||
<AuthLoginWithEmailBlock
|
||||
v-if="hasLocalStrategy"
|
||||
:challenge="challenge"
|
||||
:workspace-invite="workspaceInvite || undefined"
|
||||
/>
|
||||
<div v-if="!forcedInviteEmail" class="text-center text-body-sm">
|
||||
<span class="mr-2">Don't have an account?</span>
|
||||
<CommonTextLink :to="finalRegisterRoute" :icon-right="ArrowRightIcon">
|
||||
Register
|
||||
@@ -43,10 +49,13 @@
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
|
||||
import { useLoginOrRegisterUtils, useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
|
||||
import { LayoutDialog } from '@speckle/ui-components'
|
||||
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
|
||||
import { registerRoute } from '~~/lib/common/helpers/route'
|
||||
import {
|
||||
authLoginPanelQuery,
|
||||
authLoginPanelWorkspaceInviteQuery
|
||||
} from '~/lib/auth/graphql/queries'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -61,9 +70,23 @@ const props = withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const { appId, challenge } = useLoginOrRegisterUtils()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const { inviteToken } = useAuthManager()
|
||||
const router = useRouter()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const { result } = useQuery(authLoginPanelQuery)
|
||||
|
||||
const { result: workspaceInviteResult } = useQuery(
|
||||
authLoginPanelWorkspaceInviteQuery,
|
||||
() => ({
|
||||
token: inviteToken.value
|
||||
}),
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value
|
||||
})
|
||||
)
|
||||
|
||||
const finalRegisterRoute = computed(() => {
|
||||
const result = router.resolve({
|
||||
@@ -77,8 +100,8 @@ const concreteComponent = computed(() => {
|
||||
return props.dialogMode ? LayoutDialog : 'div'
|
||||
})
|
||||
|
||||
const { result } = useQuery(loginServerInfoQuery)
|
||||
const { appId, challenge } = useLoginOrRegisterUtils()
|
||||
const workspaceInvite = computed(() => workspaceInviteResult.value?.workspaceInvite)
|
||||
const forcedInviteEmail = computed(() => workspaceInvite.value?.email)
|
||||
|
||||
const serverInfo = computed(() => result.value?.serverInfo)
|
||||
const hasLocalStrategy = computed(() =>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
color="foundation"
|
||||
:rules="emailRules"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
:disabled="!!(loading || shouldForceInviteEmail)"
|
||||
auto-focus
|
||||
/>
|
||||
<FormTextInput
|
||||
@@ -49,14 +49,27 @@ import { ensureError } from '@speckle/shared'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { forgottenPasswordRoute } from '~~/lib/common/helpers/route'
|
||||
import { useMounted } from '@vueuse/core'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
type FormValues = { email: string; password: string }
|
||||
|
||||
graphql(`
|
||||
fragment AuthLoginWithEmailBlock_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
|
||||
id
|
||||
email
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
challenge: string
|
||||
workspaceInvite?: AuthLoginWithEmailBlock_PendingWorkspaceCollaboratorFragment
|
||||
}>()
|
||||
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
const { handleSubmit, setValues } = useForm<FormValues>()
|
||||
|
||||
const loading = ref(false)
|
||||
const emailRules = [isEmail]
|
||||
@@ -66,6 +79,12 @@ const isMounted = useMounted()
|
||||
const { loginWithEmail } = useAuthManager()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const inviteEmail = computed(() => props.workspaceInvite?.email)
|
||||
const isInviteForExistingUser = computed(() => !!props.workspaceInvite?.user)
|
||||
const shouldForceInviteEmail = computed(
|
||||
() => !!(inviteEmail.value && isInviteForExistingUser.value)
|
||||
)
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email, password }) => {
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -80,4 +99,14 @@ const onSubmit = handleSubmit(async ({ email, password }) => {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
shouldForceInviteEmail,
|
||||
(shouldForce) => {
|
||||
if (shouldForce) {
|
||||
setValues({ email: inviteEmail.value || '' })
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="--mx-auto w-full">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col items-center gap-y-2">
|
||||
<div v-if="!workspaceInvite" class="flex flex-col items-center gap-y-2">
|
||||
<h1 class="text-heading-xl text-center inline-block">
|
||||
Create your Speckle account
|
||||
</h1>
|
||||
@@ -9,6 +9,7 @@
|
||||
Connectivity, Collaboration and Automation for 3D
|
||||
</h2>
|
||||
</div>
|
||||
<AuthWorkspaceInviteHeader v-else :invite="workspaceInvite" />
|
||||
<template v-if="isInviteOnly && !inviteToken">
|
||||
<div class="flex space-x-2 items-center">
|
||||
<ExclamationTriangleIcon class="h-8 w-8 text-warning" />
|
||||
@@ -17,7 +18,7 @@
|
||||
follow the instructions in it.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 items-center justify-center">
|
||||
<div v-if="!inviteEmail" class="flex space-x-2 items-center justify-center">
|
||||
<span>Already have an account?</span>
|
||||
<CommonTextLink :to="loginRoute">Log in</CommonTextLink>
|
||||
</div>
|
||||
@@ -28,6 +29,7 @@
|
||||
:server-info="serverInfo"
|
||||
:challenge="challenge"
|
||||
:app-id="appId"
|
||||
:newsletter-consent="newsletterConsent"
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
@@ -38,6 +40,7 @@
|
||||
</div>
|
||||
<AuthRegisterWithEmailBlock
|
||||
v-if="serverInfo && hasLocalStrategy"
|
||||
v-model:newsletter-consent="newsletterConsent"
|
||||
:challenge="challenge"
|
||||
:server-info="serverInfo"
|
||||
:invite-email="inviteEmail"
|
||||
@@ -51,19 +54,20 @@
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { AuthStrategy } from '~~/lib/auth/helpers/strategies'
|
||||
import { useLoginOrRegisterUtils } from '~~/lib/auth/composables/auth'
|
||||
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
|
||||
import { loginRoute } from '~~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment AuthRegisterPanelServerInfo on ServerInfo {
|
||||
inviteOnly
|
||||
}
|
||||
`)
|
||||
|
||||
const serverInviteQuery = graphql(`
|
||||
query RegisterPanelServerInvite($token: String!) {
|
||||
const registerPanelQuery = graphql(`
|
||||
query AuthRegisterPanel($token: String) {
|
||||
serverInfo {
|
||||
inviteOnly
|
||||
authStrategies {
|
||||
id
|
||||
}
|
||||
...AuthStategiesServerInfoFragment
|
||||
...ServerTermsOfServicePrivacyPolicyFragment
|
||||
}
|
||||
serverInviteByToken(token: $token) {
|
||||
id
|
||||
email
|
||||
@@ -71,21 +75,33 @@ const serverInviteQuery = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
const newsletterConsent = ref(false)
|
||||
|
||||
provide('newsletterconsent', newsletterConsent)
|
||||
|
||||
const { result } = useQuery(loginServerInfoQuery)
|
||||
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
|
||||
const { result: inviteMetadata } = useQuery(
|
||||
serverInviteQuery,
|
||||
() => ({ token: inviteToken.value || '' }),
|
||||
{
|
||||
enabled: computed(() => !!inviteToken.value?.length)
|
||||
const registerPanelWorkspaceInviteQuery = graphql(`
|
||||
query AuthRegisterPanelWorkspaceInvite($token: String) {
|
||||
workspaceInvite(token: $token) {
|
||||
id
|
||||
...AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const { appId, challenge, inviteToken } = useLoginOrRegisterUtils()
|
||||
const { result } = useQuery(registerPanelQuery, () => ({
|
||||
token: inviteToken.value
|
||||
}))
|
||||
const { result: workspaceInviteResult } = useQuery(
|
||||
registerPanelWorkspaceInviteQuery,
|
||||
() => ({
|
||||
token: inviteToken.value
|
||||
}),
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value
|
||||
})
|
||||
)
|
||||
|
||||
const inviteEmail = computed(() => inviteMetadata.value?.serverInviteByToken?.email)
|
||||
const newsletterConsent = ref(false)
|
||||
|
||||
const inviteEmail = computed(() => result.value?.serverInviteByToken?.email)
|
||||
const serverInfo = computed(() => result.value?.serverInfo)
|
||||
const hasLocalStrategy = computed(() =>
|
||||
(serverInfo.value?.authStrategies || []).some((s) => s.id === AuthStrategy.Local)
|
||||
@@ -96,4 +112,5 @@ const hasThirdPartyStrategies = computed(() =>
|
||||
)
|
||||
|
||||
const isInviteOnly = computed(() => !!serverInfo.value?.inviteOnly)
|
||||
const workspaceInvite = computed(() => workspaceInviteResult.value?.workspaceInvite)
|
||||
</script>
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
<template>
|
||||
<form method="post" @submit="onSubmit">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<FormTextInput
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full name"
|
||||
placeholder="My name"
|
||||
size="lg"
|
||||
:rules="nameRules"
|
||||
color="foundation"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
auto-focus
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
@@ -26,6 +14,18 @@
|
||||
show-label
|
||||
:disabled="isEmailDisabled"
|
||||
/>
|
||||
<FormTextInput
|
||||
type="text"
|
||||
name="name"
|
||||
label="Full name"
|
||||
placeholder="My name"
|
||||
size="lg"
|
||||
:rules="nameRules"
|
||||
color="foundation"
|
||||
show-label
|
||||
:disabled="loading"
|
||||
auto-focus
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
@@ -66,7 +66,7 @@
|
||||
class="mt-2 text-body-2xs text-foreground-2 text-center terms-of-service"
|
||||
v-html="serverInfo.termsOfService"
|
||||
/>
|
||||
<div class="mt-2 sm:mt-4 text-center text-body-sm">
|
||||
<div v-if="!inviteEmail" class="mt-2 sm:mt-4 text-center text-body-sm">
|
||||
<span class="mr-2">Already have an account?</span>
|
||||
<CommonTextLink :to="finalLoginRoute" :icon-right="ArrowRightIcon">
|
||||
Log in
|
||||
@@ -111,8 +111,9 @@ const { handleSubmit } = useForm<FormValues>()
|
||||
const router = useRouter()
|
||||
const { signUpWithEmail, inviteToken } = useAuthManager()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
|
||||
const isMounted = useMounted()
|
||||
|
||||
const newsletterConsent = defineModel<boolean>('newsletterConsent', { required: true })
|
||||
const loading = ref(false)
|
||||
const password = ref('')
|
||||
const email = ref('')
|
||||
@@ -120,8 +121,6 @@ const email = ref('')
|
||||
const emailRules = [isEmail]
|
||||
const nameRules = [isRequired]
|
||||
|
||||
const newsletterConsent = inject<Ref<boolean>>('newsletterconsent')
|
||||
|
||||
const isEmailDisabled = computed(() => !!props.inviteEmail?.length || loading.value)
|
||||
|
||||
const finalLoginRoute = computed(() => {
|
||||
@@ -140,7 +139,7 @@ const onSubmit = handleSubmit(async (fullUser) => {
|
||||
user,
|
||||
challenge: props.challenge,
|
||||
inviteToken: inviteToken.value,
|
||||
newsletter: newsletterConsent?.value
|
||||
newsletter: newsletterConsent.value
|
||||
})
|
||||
} catch (e) {
|
||||
triggerNotification({
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="shouldShowBanner"
|
||||
class="flex flex-col mx-2 mt-1 mb-2 px-2 py-1.5 text-dark border border-outline-2 bg-foundation-1 rounded-md"
|
||||
>
|
||||
<div class="text-body-xs">{{ verifyBannerText }}</div>
|
||||
<div class="">
|
||||
<FormButton
|
||||
size="sm"
|
||||
text
|
||||
:disabled="loading"
|
||||
link
|
||||
class="font-medium text-danger-darker"
|
||||
@click="requestVerification"
|
||||
>
|
||||
{{ verifyBannerCtaText }}
|
||||
</FormButton>
|
||||
<!-- <CommonTextLink @click="dismiss">
|
||||
<XMarkIcon class="h-6 w-6" />
|
||||
</CommonTextLink> -->
|
||||
</div>
|
||||
<div v-if="shouldShowBanner" class="flex flex-col px-3 pb-3">
|
||||
<div class="text-body-2xs text-foreground mb-3">{{ verifyBannerText }}</div>
|
||||
<FormButton
|
||||
size="sm"
|
||||
color="outline"
|
||||
:disabled="loading"
|
||||
@click="requestVerification"
|
||||
>
|
||||
{{ verifyBannerCtaText }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<div v-else-if="noticeLoading">
|
||||
<CommonLoadingIcon size="sm" class="my-2 mx-auto" />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="space-y-8 mb-8">
|
||||
<h1 class="text-heading-xl text-center">Join workspace</h1>
|
||||
<div class="p-4 border border-outline-2 rounded text-body-xs">
|
||||
You're accepting an invitation to join
|
||||
<span class="font-medium">{{ invite.workspaceName }}</span>
|
||||
<!-- prettier-ignore -->
|
||||
<template v-if="invite.user">
|
||||
as
|
||||
<div class="inline-flex items-center">
|
||||
<UserAvatar :user="invite.user" size="sm" class="mr-1" />
|
||||
<span class="font-medium">{{ invite.user.name }}</span>
|
||||
</div>.
|
||||
</template>
|
||||
<template v-else>
|
||||
using the
|
||||
<span class="font-medium">{{ invite.email }}</span>
|
||||
email address.
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment AuthWorkspaceInviteHeader_PendingWorkspaceCollaborator on PendingWorkspaceCollaborator {
|
||||
id
|
||||
workspaceName
|
||||
email
|
||||
user {
|
||||
id
|
||||
...LimitedUserAvatar
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
invite: AuthWorkspaceInviteHeader_PendingWorkspaceCollaboratorFragment
|
||||
}>()
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@
|
||||
v-for="strat in thirdPartyStrategies"
|
||||
:key="strat.id"
|
||||
to="javascript:;"
|
||||
:server-info="serverInfo"
|
||||
@click="() => onClick(strat)"
|
||||
/>
|
||||
</div>
|
||||
@@ -35,6 +36,7 @@ graphql(`
|
||||
name
|
||||
url
|
||||
}
|
||||
...AuthThirdPartyLoginButtonOIDC_ServerInfo
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -42,14 +44,13 @@ const props = defineProps<{
|
||||
serverInfo: AuthStategiesServerInfoFragmentFragment
|
||||
challenge: string
|
||||
appId: string
|
||||
newsletterConsent: boolean
|
||||
}>()
|
||||
|
||||
const apiOrigin = useApiOrigin()
|
||||
const mixpanel = useMixpanel()
|
||||
const { inviteToken } = useAuthManager()
|
||||
|
||||
const newsletterConsent = inject<Ref<boolean>>('newsletterconsent')
|
||||
|
||||
const NuxtLink = resolveComponent('NuxtLink')
|
||||
const GoogleButton = resolveComponent('AuthThirdPartyLoginButtonGoogle')
|
||||
const MicrosoftButton = resolveComponent('AuthThirdPartyLoginButtonMicrosoft')
|
||||
@@ -69,7 +70,7 @@ const buildAuthUrl = (strat: StrategyType) => {
|
||||
url.searchParams.set('token', inviteToken.value)
|
||||
}
|
||||
|
||||
if (newsletterConsent?.value) {
|
||||
if (props.newsletterConsent) {
|
||||
url.searchParams.set('newsletter', 'true')
|
||||
}
|
||||
|
||||
|
||||
@@ -6,15 +6,24 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { IdentificationIcon } from '@heroicons/vue/24/outline'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { loginServerInfoQuery } from '~~/lib/auth/graphql/queries'
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { AuthThirdPartyLoginButtonOidc_ServerInfoFragment } from '~/lib/common/generated/gql/graphql'
|
||||
|
||||
defineProps<{
|
||||
graphql(`
|
||||
fragment AuthThirdPartyLoginButtonOIDC_ServerInfo on ServerInfo {
|
||||
authStrategies {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
to: string
|
||||
serverInfo: AuthThirdPartyLoginButtonOidc_ServerInfoFragment
|
||||
}>()
|
||||
|
||||
const { result } = useQuery(loginServerInfoQuery)
|
||||
const authStrategies = computed(() => result.value?.serverInfo.authStrategies)
|
||||
const authStrategies = computed(() => props.serverInfo.authStrategies)
|
||||
|
||||
const oidcName = computed(() => {
|
||||
const oidcStrategy = authStrategies.value?.find((strategy) => strategy.id === 'oidc')
|
||||
|
||||
@@ -204,7 +204,7 @@ const enableSubmitTestAutomation = computed(() => {
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return isTestAutomation.value ? undefined : 'Create Automation'
|
||||
return isTestAutomation.value ? undefined : 'Create automation'
|
||||
})
|
||||
|
||||
const buttons = computed((): LayoutDialogButton[] => {
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
allow-unset
|
||||
button-style="tinted"
|
||||
clearable
|
||||
placeholder="Choose a GitHub organization (optional)"
|
||||
show-optional
|
||||
placeholder="Choose a GitHub organization"
|
||||
help="Choose an organization to publish your Git repository to. If left empty, it will be published to your personal account."
|
||||
:items="githubOrgs"
|
||||
mount-menu-on-body
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(item, index) in billingItems"
|
||||
:key="item.name"
|
||||
class="text-body-xs flex"
|
||||
:class="[index === billingItems.length - 1 ? 'border-b border-outline-3' : null]"
|
||||
>
|
||||
<p
|
||||
class="text-foreground flex-1 py-2 px-3"
|
||||
:class="[index < billingItems.length - 1 ? 'border-b border-outline-3' : null]"
|
||||
>
|
||||
{{ item.label }}
|
||||
<span class="text-foreground-2">x</span>
|
||||
£{{ item.cost }}
|
||||
</p>
|
||||
<p
|
||||
class="text-right text-foreground ml-4 w-32 md:w-40 py-2 px-3"
|
||||
:class="[index < billingItems.length - 1 ? 'border-b border-outline-3' : null]"
|
||||
>
|
||||
£{{ item.count * item.cost }} / month
|
||||
</p>
|
||||
</li>
|
||||
<li class="flex justify-between text-foreground font-medium">
|
||||
<p class="flex-1 p-3">Total</p>
|
||||
<p class="text-right w-32 md:w-40 ml-4 p-3">
|
||||
£{{ workspaceCost.subTotal }} / month
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { graphql } from '~/lib/common/generated/gql'
|
||||
import type { BillingSummary_WorkspaceCostFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
|
||||
graphql(`
|
||||
fragment BillingSummary_WorkspaceCost on WorkspaceCost {
|
||||
items {
|
||||
cost
|
||||
count
|
||||
name
|
||||
label
|
||||
}
|
||||
discount {
|
||||
amount
|
||||
name
|
||||
}
|
||||
subTotal
|
||||
total
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
workspaceCost: BillingSummary_WorkspaceCostFragment
|
||||
}>()
|
||||
|
||||
const billingItems = computed(() => props.workspaceCost.items)
|
||||
</script>
|
||||
@@ -1,7 +1,21 @@
|
||||
<template>
|
||||
<div class="border border-outline-3 rounded-lg p-5 pt-4">
|
||||
<p class="text-heading-sm text-foreground">{{ title }}</p>
|
||||
<p class="text-body-xs text-foreground-2 pt-1">{{ description }}</p>
|
||||
<div class="border border-outline-3 rounded-lg p-5 flex flex-col">
|
||||
<div v-if="$slots.icon" class="mb-4">
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<div v-if="title" class="flex items-center gap-2">
|
||||
<p class="text-heading-sm text-foreground">{{ title }}</p>
|
||||
<CommonBadge v-if="badge" rounded>{{ badge }}</CommonBadge>
|
||||
</div>
|
||||
|
||||
<p v-if="description" class="text-body-xs text-foreground-2 pt-1">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
|
||||
<div
|
||||
v-if="buttons"
|
||||
@@ -13,6 +27,8 @@
|
||||
v-bind="button.props || {}"
|
||||
:disabled="button.props?.disabled || button.disabled"
|
||||
:submit="button.props?.submit || button.submit"
|
||||
target="_blank"
|
||||
external
|
||||
size="sm"
|
||||
color="outline"
|
||||
@click="($event) => button.onClick?.($event)"
|
||||
@@ -27,8 +43,9 @@
|
||||
import { type LayoutDialogButton } from '@speckle/ui-components'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
title?: string
|
||||
description?: string
|
||||
buttons?: LayoutDialogButton[]
|
||||
badge?: string
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="open" max-width="xs" :buttons="dialogButtons">
|
||||
<template #header>Discard changes?</template>
|
||||
<p v-if="text" class="mb-2">{{ text }}</p>
|
||||
<p v-else class="mb-2">You have unsaved changes. Are you sure you want to leave?</p>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
|
||||
defineProps<{
|
||||
text?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['confirm'])
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => {
|
||||
return [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
open.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
onClick: () => {
|
||||
open.value = false
|
||||
emit('confirm')
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<CommonEmptyState :cta="cta">
|
||||
No items matching your search query found!
|
||||
No items matching your search query found
|
||||
</CommonEmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<CommonEmptyState :cta="cta">
|
||||
{{ search ? 'No items matching your search query found!' : message }}
|
||||
{{ search ? 'No items matching your search query found' : message }}
|
||||
</CommonEmptyState>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-heading-xl">{{ title }}</h2>
|
||||
<p v-if="description" class="text-body-sm text-foreground-2">{{ description }}</p>
|
||||
<p v-else class="text-body-sm text-foreground-2 italic select-none">
|
||||
No description
|
||||
<div class="flex flex-col">
|
||||
<h2 class="text-heading">{{ title }}</h2>
|
||||
<p class="text-body-sm text-foreground-2 line-clamp-2">
|
||||
{{ description ? description : 'No description' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,17 +8,33 @@
|
||||
>
|
||||
{{ project.name }}
|
||||
</NuxtLink>
|
||||
<div class="flex-1">
|
||||
<p class="text-body-3xs text-foreground-2 capitalize">
|
||||
{{ project.role?.split(':').reverse()[0] }}
|
||||
<div class="flex-1 gap-y-3">
|
||||
<p class="text-body-3xs text-foreground-2">
|
||||
<span class="capitalize">
|
||||
{{ project.role?.split(':').reverse()[0] }}
|
||||
</span>
|
||||
<span class="pl-1 pr-2">•</span>
|
||||
<span v-tippy="updatedAt.full">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
</p>
|
||||
<UserAvatarGroup :users="teamUsers" :max-count="4" class="pt-3 -ml-0.5" />
|
||||
</div>
|
||||
<UserAvatarGroup :users="teamUsers" :max-count="4" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-y-3 pt-1">
|
||||
<NuxtLink
|
||||
v-if="project.workspace && isWorkspacesEnabled"
|
||||
:to="workspaceRoute(project.workspace.slug)"
|
||||
class="flex items-center"
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
:logo="project.workspace.logo"
|
||||
:default-logo-index="project.workspace.defaultLogoIndex"
|
||||
size="sm"
|
||||
/>
|
||||
<p class="text-body-2xs text-foreground ml-2">
|
||||
{{ project.workspace.name }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
<FormButton
|
||||
:to="allProjectModelsRoute(project.id)"
|
||||
size="sm"
|
||||
@@ -41,6 +57,7 @@ import type { DashboardProjectCard_ProjectFragment } from '~~/lib/common/generat
|
||||
import { projectRoute, allProjectModelsRoute } from '~~/lib/common/helpers/route'
|
||||
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
|
||||
import { useGeneralProjectPageUpdateTracking } from '~~/lib/projects/composables/projectPages'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment DashboardProjectCard_Project on Project {
|
||||
@@ -56,6 +73,12 @@ graphql(`
|
||||
...LimitedUserAvatar
|
||||
}
|
||||
}
|
||||
workspace {
|
||||
id
|
||||
slug
|
||||
name
|
||||
...WorkspaceAvatar_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -63,6 +86,8 @@ const props = defineProps<{
|
||||
project: DashboardProjectCard_ProjectFragment
|
||||
}>()
|
||||
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const projectId = computed(() => props.project.id)
|
||||
|
||||
useGeneralProjectPageUpdateTracking(
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<template>
|
||||
<div class="group">
|
||||
<template v-if="isLoggedIn">
|
||||
<Portal to="mobile-navigation">
|
||||
<div class="lg:hidden">
|
||||
<FormButton
|
||||
:color="isOpenMobile ? 'outline' : 'subtle'"
|
||||
size="sm"
|
||||
class="mt-px"
|
||||
@click="isOpenMobile = !isOpenMobile"
|
||||
>
|
||||
<IconSidebar v-if="!isOpenMobile" class="h-4 w-4 -ml-1 -mr-1" />
|
||||
<IconSidebarClose v-else class="h-4 w-4 -ml-1 -mr-1" />
|
||||
</FormButton>
|
||||
</div>
|
||||
</Portal>
|
||||
<div
|
||||
v-keyboard-clickable
|
||||
class="lg:hidden absolute inset-0 backdrop-blur-sm z-40 transition-all"
|
||||
:class="isOpenMobile ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
@click="isOpenMobile = false"
|
||||
/>
|
||||
<div
|
||||
class="absolute z-40 lg:static h-full flex w-[17rem] shrink-0 transition-all"
|
||||
:class="isOpenMobile ? '' : '-translate-x-[17rem] lg:translate-x-0'"
|
||||
>
|
||||
<LayoutSidebar
|
||||
class="border-r border-outline-3 px-2 pt-3 pb-2 bg-foundation-page"
|
||||
>
|
||||
<LayoutSidebarMenu>
|
||||
<LayoutSidebarMenuGroup>
|
||||
<NuxtLink :to="homeRoute" @click="isOpenMobile = false">
|
||||
<LayoutSidebarMenuGroupItem
|
||||
label="Dashboard"
|
||||
:active="isActive(homeRoute)"
|
||||
>
|
||||
<template #icon>
|
||||
<HomeIcon class="size-4 stroke-[1.5px]" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink :to="projectsRoute" @click="isOpenMobile = false">
|
||||
<LayoutSidebarMenuGroupItem
|
||||
label="Projects"
|
||||
:active="isActive(projectsRoute)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconProjects class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
</LayoutSidebarMenuGroup>
|
||||
|
||||
<LayoutSidebarMenuGroup
|
||||
v-if="isWorkspacesEnabled"
|
||||
collapsible
|
||||
title="Workspaces"
|
||||
:plus-click="isNotGuest ? handlePlusClick : undefined"
|
||||
plus-text="Create workspace"
|
||||
>
|
||||
<NuxtLink :to="workspacesRoute" @click="handleIntroducingWorkspacesClick">
|
||||
<LayoutSidebarMenuGroupItem
|
||||
label="Introducing workspaces"
|
||||
:active="isActive(workspacesRoute)"
|
||||
tag="BETA"
|
||||
>
|
||||
<template #icon>
|
||||
<IconWorkspaces class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-for="(item, key) in workspacesItems"
|
||||
:key="key"
|
||||
:to="item.to"
|
||||
@click="isOpenMobile = false"
|
||||
>
|
||||
<LayoutSidebarMenuGroupItem
|
||||
:label="item.label"
|
||||
:active="isActive(item.to)"
|
||||
class="!pl-1"
|
||||
>
|
||||
<template #icon>
|
||||
<WorkspaceAvatar
|
||||
:logo="item.logo"
|
||||
:default-logo-index="item.defaultLogoIndex"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
</LayoutSidebarMenuGroup>
|
||||
|
||||
<LayoutSidebarMenuGroup title="Resources" collapsible>
|
||||
<NuxtLink
|
||||
:to="downloadManagerUrl"
|
||||
target="_blank"
|
||||
@click="isOpenMobile = false"
|
||||
>
|
||||
<LayoutSidebarMenuGroupItem label="Connectors" external>
|
||||
<template #icon>
|
||||
<IconConnectors class="size-4 ml-px text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="https://speckle.community/"
|
||||
target="_blank"
|
||||
@click="isOpenMobile = false"
|
||||
>
|
||||
<LayoutSidebarMenuGroupItem label="Community forum" external>
|
||||
<template #icon>
|
||||
<IconCommunity class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
|
||||
<div @click="openFeedbackDialog">
|
||||
<LayoutSidebarMenuGroupItem label="Give us feedback">
|
||||
<template #icon>
|
||||
<IconFeedback class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
to="https://speckle.guide/"
|
||||
target="_blank"
|
||||
@click="isOpenMobile = false"
|
||||
>
|
||||
<LayoutSidebarMenuGroupItem label="Documentation" external>
|
||||
<template #icon>
|
||||
<IconDocumentation class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="https://speckle.community/c/making-speckle/changelog"
|
||||
target="_blank"
|
||||
@click="isOpenMobile = false"
|
||||
>
|
||||
<LayoutSidebarMenuGroupItem label="Changelog" external>
|
||||
<template #icon>
|
||||
<IconChangelog class="size-4 text-foreground-2" />
|
||||
</template>
|
||||
</LayoutSidebarMenuGroupItem>
|
||||
</NuxtLink>
|
||||
</LayoutSidebarMenuGroup>
|
||||
</LayoutSidebarMenu>
|
||||
<template #promo>
|
||||
<LayoutSidebarPromo
|
||||
title="SpeckleCon 2024"
|
||||
text="Join us in London on Nov 13-14 for the ultimate community event."
|
||||
button-text="Get tickets"
|
||||
@on-click="onPromoClick"
|
||||
/>
|
||||
</template>
|
||||
</LayoutSidebar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FeedbackDialog v-model:open="showFeedbackDialog" />
|
||||
|
||||
<WorkspaceCreateDialog
|
||||
v-model:open="showWorkspaceCreateDialog"
|
||||
navigate-on-success
|
||||
event-source="sidebar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FormButton,
|
||||
LayoutSidebar,
|
||||
LayoutSidebarPromo,
|
||||
LayoutSidebarMenu,
|
||||
LayoutSidebarMenuGroup,
|
||||
LayoutSidebarMenuGroupItem
|
||||
} from '@speckle/ui-components'
|
||||
import { settingsSidebarQuery } from '~/lib/settings/graphql/queries'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import {
|
||||
homeRoute,
|
||||
projectsRoute,
|
||||
workspaceRoute,
|
||||
workspacesRoute,
|
||||
downloadManagerUrl
|
||||
} from '~/lib/common/helpers/route'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { HomeIcon } from '@heroicons/vue/24/outline'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { Roles } from '@speckle/shared'
|
||||
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { activeUser: user } = useActiveUser()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const isOpenMobile = ref(false)
|
||||
const showWorkspaceCreateDialog = ref(false)
|
||||
const showFeedbackDialog = ref(false)
|
||||
|
||||
const { result: workspaceResult, onResult: onWorkspaceResult } = useQuery(
|
||||
settingsSidebarQuery,
|
||||
null,
|
||||
{
|
||||
enabled: isWorkspacesEnabled.value
|
||||
}
|
||||
)
|
||||
|
||||
const isActive = (...routes: string[]): boolean => {
|
||||
return routes.some((routeTo) => route.path === routeTo)
|
||||
}
|
||||
|
||||
const isNotGuest = computed(
|
||||
() => Roles.Server.Admin || user.value?.role === Roles.Server.User
|
||||
)
|
||||
|
||||
const workspacesItems = computed(() =>
|
||||
workspaceResult.value?.activeUser
|
||||
? workspaceResult.value.activeUser.workspaces.items.map((workspace) => ({
|
||||
label: workspace.name,
|
||||
id: workspace.id,
|
||||
to: workspaceRoute(workspace.slug),
|
||||
logo: workspace.logo,
|
||||
defaultLogoIndex: workspace.defaultLogoIndex
|
||||
}))
|
||||
: []
|
||||
)
|
||||
|
||||
onWorkspaceResult((result) => {
|
||||
if (result.data?.activeUser) {
|
||||
const workspaceIds = result.data.activeUser.workspaces.items.map(
|
||||
(workspace) => workspace.id
|
||||
)
|
||||
|
||||
if (workspaceIds.length > 0) {
|
||||
mixpanel.people.set('workspace_id', workspaceIds)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onPromoClick = () => {
|
||||
mixpanel.track('Promo Banner Clicked', {
|
||||
source: 'sidebar',
|
||||
campaign: 'specklecon2024'
|
||||
})
|
||||
|
||||
window.open('https://conf.speckle.systems/', '_blank')
|
||||
}
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
showFeedbackDialog.value = true
|
||||
isOpenMobile.value = false
|
||||
}
|
||||
|
||||
const openWorkspaceCreateDialog = () => {
|
||||
showWorkspaceCreateDialog.value = true
|
||||
mixpanel.track('Create Workspace Button Clicked', {
|
||||
source: 'sidebar'
|
||||
})
|
||||
}
|
||||
|
||||
const handlePlusClick = () => {
|
||||
if (route.path === workspacesRoute) {
|
||||
openWorkspaceCreateDialog()
|
||||
} else {
|
||||
mixpanel.track('Clicked Link to Workspace Explainer', {
|
||||
source: 'sidebar'
|
||||
})
|
||||
router.push(workspacesRoute)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIntroducingWorkspacesClick = () => {
|
||||
isOpenMobile.value = false
|
||||
mixpanel.track('Clicked Link to Workspace Explainer', {
|
||||
source: 'sidebar'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
<h3 v-if="tutorial.title" class="text-body-2xs text-foreground truncate">
|
||||
{{ tutorial.title }}
|
||||
</h3>
|
||||
<p class="text-body-3xs text-foreground-2 capitalize mt-2">
|
||||
<p class="text-body-3xs text-foreground-2 mt-2">
|
||||
<span v-tippy="updatedAt.full">
|
||||
{{ updatedAt.relative }}
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<!-- <ErrorPageProjectInviteBanner /> -->
|
||||
<h1 class="h1 font-medium">Error {{ error.statusCode || 500 }}</h1>
|
||||
<div class="flex flex-col items-center space-y-1">
|
||||
<h2 class="h2 text-foreground-2 text-center mx-4 break-words max-w-full">
|
||||
<h1 class="text-heading-2xl">Error {{ error.statusCode || 500 }}</h1>
|
||||
<div class="flex flex-col items-center space-y-2">
|
||||
<h2 class="text-heading-lg text-foreground-2 mx-4 break-words max-w-full">
|
||||
{{ error.message }}
|
||||
</h2>
|
||||
<button
|
||||
@@ -21,7 +21,7 @@
|
||||
class="max-w-xl text-body-xs text-foreground-2"
|
||||
v-html="error.stack"
|
||||
/>
|
||||
<FormButton :to="homeRoute" size="lg">Go Home</FormButton>
|
||||
<FormButton :to="homeRoute">Go home</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-8 mt-12">
|
||||
<div class="flex flex-col justify-center sm:flex-row sm:space-x-2 items-center">
|
||||
<LockClosedIcon class="w-12 h-12 text-primary shrink-0" />
|
||||
<h1 class="text-heading-xl">
|
||||
You are not authorized to access this {{ resourceType }}.
|
||||
<h1 class="text-heading-lg text-foreground">
|
||||
You are not authorized to access this {{ resourceType }}
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:space-x-2 items-center"
|
||||
>
|
||||
<FormButton v-if="!isLoggedIn" size="lg" full-width @click="() => goToLogin()">
|
||||
<FormButton full-width color="outline" :to="homeRoute">Go home</FormButton>
|
||||
<FormButton v-if="!isLoggedIn" full-width @click="() => goToLogin()">
|
||||
Sign in
|
||||
</FormButton>
|
||||
<FormButton
|
||||
size="lg"
|
||||
full-width
|
||||
:color="isLoggedIn ? 'primary' : 'outline'"
|
||||
:to="homeRoute"
|
||||
>
|
||||
Go home
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/24/solid'
|
||||
import { useRememberRouteAndGoToLogin, homeRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
withDefaults(
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center space-y-8">
|
||||
<ErrorPageProjectAccessErrorBlock
|
||||
v-if="isNoProjectAccessError"
|
||||
:error="finalError"
|
||||
/>
|
||||
<ErrorPageProjectAccessErrorBlock v-if="isNoProjectAccessError" />
|
||||
<ErrorPageWorkspaceAccessErrorBlock v-else-if="isNoWorkspaceAccessError" />
|
||||
<ErrorPageGenericErrorBlock v-else :error="finalError" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,4 +22,9 @@ const isNoProjectAccessError = computed(
|
||||
finalError.value.statusCode === 403 &&
|
||||
finalError.value.message.includes('You do not have access to this project')
|
||||
)
|
||||
const isNoWorkspaceAccessError = computed(
|
||||
() =>
|
||||
finalError.value.statusCode === 403 &&
|
||||
finalError.value.message.includes('You do not have access to this workspace')
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<NuxtErrorBoundary @error="onError">
|
||||
<WorkspaceInviteBlock v-if="invite" :invite="invite" />
|
||||
<ErrorPageGenericUnauthorizedBlock v-else resource-type="workspace" />
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { type Optional } from '@speckle/shared'
|
||||
import { useQuery } from '@vue/apollo-composable'
|
||||
import { workspaceInviteQuery } from '~/lib/workspaces/graphql/queries'
|
||||
|
||||
const route = useRoute()
|
||||
const logger = useLogger()
|
||||
|
||||
const token = computed(() => route.query.token as Optional<string>)
|
||||
const workspaceSlug = computed(() => route.params.slug as Optional<string>)
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
|
||||
const { result } = useQuery(
|
||||
workspaceInviteQuery,
|
||||
() => ({
|
||||
workspaceId: workspaceSlug.value,
|
||||
token: token.value,
|
||||
options: {
|
||||
useSlug: true
|
||||
}
|
||||
}),
|
||||
() => ({ enabled: !!(workspaceSlug.value && isWorkspacesEnabled.value) })
|
||||
)
|
||||
|
||||
const invite = computed(() => result.value?.workspaceInvite)
|
||||
|
||||
const onError = (err: unknown) => logger.error(err)
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
title="Give us feedback"
|
||||
:buttons="dialogButtons"
|
||||
:on-submit="onSubmit"
|
||||
max-width="md"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-body-xs text-foreground font-medium">
|
||||
How can we improve Speckle? If you have a feature request, please also share how
|
||||
you would use it and why it's important to you
|
||||
</p>
|
||||
<FormTextArea
|
||||
v-model="feedback"
|
||||
:rules="[isRequired]"
|
||||
name="feedback"
|
||||
label="Feedback"
|
||||
color="foundation"
|
||||
/>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LayoutDialogButton } from '@speckle/ui-components'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useMixpanel } from '~/lib/core/composables/mp'
|
||||
import { useZapier } from '~/lib/core/composables/zapier'
|
||||
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
|
||||
import { isRequired } from '~/lib/common/helpers/validation'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
type FormValues = { feedback: string }
|
||||
|
||||
const isOpen = defineModel<boolean>('open', { required: true })
|
||||
|
||||
const { activeUser: user } = useActiveUser()
|
||||
const mixpanel = useMixpanel()
|
||||
const { sendWebhook } = useZapier()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { handleSubmit } = useForm<FormValues>()
|
||||
|
||||
const feedback = ref('')
|
||||
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Send',
|
||||
props: { color: 'primary' },
|
||||
submit: true,
|
||||
id: 'sendFeedback'
|
||||
}
|
||||
])
|
||||
|
||||
const onSubmit = handleSubmit(async () => {
|
||||
if (!feedback.value) return
|
||||
|
||||
isOpen.value = false
|
||||
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'Thank you for your feedback!'
|
||||
})
|
||||
|
||||
mixpanel.track('Feedback Sent', {
|
||||
message: feedback.value
|
||||
})
|
||||
|
||||
await sendWebhook('https://hooks.zapier.com/hooks/catch/12120532/2m4okri/', {
|
||||
userId: user.value?.id ?? '',
|
||||
feedback: feedback.value
|
||||
})
|
||||
})
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
feedback.value = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,14 +1,18 @@
|
||||
<template>
|
||||
<FormSelectBase
|
||||
v-model="selectedValue"
|
||||
:items="Object.values(Roles.Stream)"
|
||||
:items="roles"
|
||||
:multiple="multiple"
|
||||
clearable
|
||||
:clearable="clearable"
|
||||
name="projectRoles"
|
||||
label="Project roles"
|
||||
class="min-w-[150px]"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
:disabled-item-tooltip="disabledItemsTooltip"
|
||||
:disabled-item-predicate="disabledItemPredicate"
|
||||
:allow-unset="allowUnset"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{ multiple ? 'Select roles' : 'Select role' }}
|
||||
@@ -21,7 +25,7 @@
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item" class="text-foreground">
|
||||
{{ roleDisplayName(item) + (i < value.length - 1 ? ', ' : '') }}
|
||||
{{ RoleInfo.Stream[item].title + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
@@ -31,21 +35,25 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="truncate text-foreground">
|
||||
{{ roleDisplayName(isArrayValue(value) ? value[0] : value) }}
|
||||
{{ RoleInfo.Stream[firstItem(value)].title }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ roleDisplayName(item) }}</span>
|
||||
<div class="flex flex-col space-y-0.5">
|
||||
<span class="truncate font-medium">
|
||||
{{ RoleInfo.Stream[firstItem(item)].title }}
|
||||
</span>
|
||||
<span class="text-body-2xs text-foreground-2">
|
||||
{{ RoleInfo.Stream[firstItem(item)].description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { Roles, RoleInfo } from '@speckle/shared'
|
||||
import type { StreamRoles, Nullable } from '@speckle/shared'
|
||||
import { capitalize } from 'lodash-es'
|
||||
import { useFormSelectChildInternals } from '~~/lib/form/composables/select'
|
||||
|
||||
type ValueType = StreamRoles | StreamRoles[] | undefined
|
||||
@@ -57,6 +65,11 @@ const emit = defineEmits<{
|
||||
const props = defineProps<{
|
||||
multiple?: boolean
|
||||
modelValue?: ValueType
|
||||
clearable?: boolean
|
||||
disabledItems?: StreamRoles[]
|
||||
disabledItemsTooltip?: string
|
||||
allowUnset?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
@@ -64,13 +77,17 @@ const itemContainer = ref(null as Nullable<HTMLElement>)
|
||||
const labelId = useId()
|
||||
const buttonId = useId()
|
||||
|
||||
const { selectedValue, isArrayValue, isMultiItemArrayValue, hiddenSelectedItemCount } =
|
||||
const { selectedValue, firstItem, isMultiItemArrayValue, hiddenSelectedItemCount } =
|
||||
useFormSelectChildInternals<StreamRoles>({
|
||||
props: toRefs(props),
|
||||
emit,
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const roleDisplayName = (role: StreamRoles) =>
|
||||
capitalize(Object.entries(Roles.Stream).find(([, val]) => val === role)?.[0] || role)
|
||||
const roles = computed(() => Object.values(Roles.Stream))
|
||||
|
||||
const disabledItemPredicate = (item: StreamRoles) =>
|
||||
props.disabledItems && props.disabledItems.length > 0
|
||||
? props.disabledItems.includes(item)
|
||||
: false
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:search="true"
|
||||
:search-placeholder="searchPlaceholder"
|
||||
:get-search-results="invokeSearch"
|
||||
:show-optional="showOptional"
|
||||
:label="label"
|
||||
:show-label="showLabel"
|
||||
:name="name || 'projects'"
|
||||
@@ -115,6 +116,13 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* Whether to show the optional text
|
||||
*/
|
||||
showOptional: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
name: {
|
||||
type: String as PropType<Optional<string>>,
|
||||
default: undefined
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
:items="roles"
|
||||
:multiple="multiple"
|
||||
:disabled-item-predicate="disabledItemPredicate"
|
||||
:disabled-item-tooltip="
|
||||
!allowGuest ? 'The Guest role isn\'t enabled on the server' : ''
|
||||
"
|
||||
name="serverRoles"
|
||||
label="Server roles"
|
||||
label="Role"
|
||||
:show-label="showLabel"
|
||||
class="min-w-[110px]"
|
||||
:fully-control-value="fullyControlValue"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
mount-menu-on-body
|
||||
size="sm"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{ multiple ? 'Select roles' : 'Select role' }}
|
||||
@@ -24,7 +27,7 @@
|
||||
class="flex flex-wrap overflow-hidden space-x-0.5 h-6"
|
||||
>
|
||||
<div v-for="(item, i) in value" :key="item" class="text-foreground">
|
||||
{{ RoleInfo.Server[item] + (i < value.length - 1 ? ', ' : '') }}
|
||||
{{ RoleInfo.Server[item].title + (i < value.length - 1 ? ', ' : '') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hiddenSelectedItemCount > 0" class="text-foreground-2 normal">
|
||||
@@ -34,13 +37,18 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="truncate text-foreground">
|
||||
{{ RoleInfo.Server[firstItem(value)] }}
|
||||
{{ RoleInfo.Server[firstItem(value)].title }}
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ RoleInfo.Server[firstItem(item)] }}</span>
|
||||
<div class="flex flex-col space-y-0.5">
|
||||
<span class="truncate font-medium">
|
||||
{{ RoleInfo.Server[firstItem(item)].title }}
|
||||
</span>
|
||||
<span class="text-body-2xs text-foreground-2">
|
||||
{{ RoleInfo.Server[firstItem(item)].description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
@@ -66,7 +74,8 @@ const props = defineProps({
|
||||
allowGuest: Boolean,
|
||||
allowAdmin: Boolean,
|
||||
allowArchived: Boolean,
|
||||
fullyControlValue: Boolean
|
||||
fullyControlValue: Boolean,
|
||||
showLabel: Boolean
|
||||
})
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
:items="roles"
|
||||
:multiple="multiple"
|
||||
name="workspaceRoles"
|
||||
label="Workspace roles"
|
||||
:label="label"
|
||||
class="min-w-[110px]"
|
||||
:label-id="labelId"
|
||||
:button-id="buttonId"
|
||||
mount-menu-on-body
|
||||
:show-label="showLabel"
|
||||
:fully-control-value="fullyControlValue"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
:disabled-item-predicate="disabledItemPredicate"
|
||||
:clearable="clearable"
|
||||
>
|
||||
<template #nothing-selected>
|
||||
{{ multiple ? 'Select roles' : 'Select role' }}
|
||||
@@ -38,14 +41,19 @@
|
||||
</template>
|
||||
</template>
|
||||
<template #option="{ item }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ RoleInfo.Workspace[firstItem(item)].title }}</span>
|
||||
<div class="flex flex-col space-y-0.5">
|
||||
<span class="truncate" :class="{ 'font-medium': !hideDescription }">
|
||||
{{ RoleInfo.Workspace[firstItem(item)].title }}
|
||||
</span>
|
||||
<span v-if="!hideDescription" class="text-body-2xs text-foreground-2">
|
||||
{{ RoleInfo.Workspace[firstItem(item)].description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</FormSelectBase>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
// Todo: Refactor this to have one component for project/server/workspace roles
|
||||
// TODO: Refactor this to have one component for project/server/workspace roles
|
||||
|
||||
import { Roles, RoleInfo } from '@speckle/shared'
|
||||
import type { Nullable, WorkspaceRoles } from '@speckle/shared'
|
||||
@@ -64,7 +72,26 @@ const props = defineProps({
|
||||
type: [String, Array] as PropType<ValueType>,
|
||||
default: undefined
|
||||
},
|
||||
fullyControlValue: Boolean
|
||||
fullyControlValue: Boolean,
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Workspace Roles'
|
||||
},
|
||||
disabled: Boolean,
|
||||
disabledItems: {
|
||||
required: false,
|
||||
type: Array as PropType<WorkspaceRoles[]>
|
||||
},
|
||||
showLabel: Boolean,
|
||||
clearable: Boolean,
|
||||
hideItems: {
|
||||
required: false,
|
||||
type: Array as PropType<WorkspaceRoles[]>
|
||||
},
|
||||
hideDescription: {
|
||||
required: false,
|
||||
type: Boolean
|
||||
}
|
||||
})
|
||||
|
||||
const elementToWatchForChanges = ref(null as Nullable<HTMLElement>)
|
||||
@@ -79,5 +106,17 @@ const { selectedValue, isMultiItemArrayValue, hiddenSelectedItemCount, firstItem
|
||||
dynamicVisibility: { elementToWatchForChanges, itemContainer }
|
||||
})
|
||||
|
||||
const roles = computed(() => Object.values(Roles.Workspace))
|
||||
const roles = computed(() => {
|
||||
if (props.hideItems && props.hideItems.length) {
|
||||
return Object.values(Roles.Workspace).filter(
|
||||
(role) => !props.hideItems?.includes(role)
|
||||
)
|
||||
}
|
||||
return Object.values(Roles.Workspace)
|
||||
})
|
||||
|
||||
const disabledItemPredicate = (item: WorkspaceRoles) =>
|
||||
props.disabledItems && props.disabledItems.length > 0
|
||||
? props.disabledItems.includes(item)
|
||||
: false
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1C6.24288 1 4.81818 2.42334 4.81818 4.18004C4.81818 5.93674 6.24288 7.36008 8 7.36008C9.75712 7.36008 11.1818 5.93674 11.1818 4.18004C11.1818 2.42334 9.75712 1 8 1Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M6.18182 9.17649C4.42465 9.17649 3 10.6005 3 12.3578V14.6281H13V12.3578C13 10.6005 11.5754 9.17649 9.81818 9.17649H6.18182Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.5 8C14.5 11.5899 11.5899 14.5 8 14.5C4.41015 14.5 1.5 11.5899 1.5 8C1.5 4.41015 4.41015 1.5 8 1.5C11.5899 1.5 14.5 4.41015 14.5 8ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM8.75 3.75C8.75 3.33579 8.41421 3 8 3C7.58579 3 7.25 3.33579 7.25 3.75V8V8.31066L7.46967 8.53033L9.72358 10.7842C10.0165 11.0771 10.4913 11.0771 10.7842 10.7842C11.0771 10.4913 11.0771 10.0165 10.7842 9.72358L8.75 7.68934V3.75Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 14.5C8.23033 14.5 8.84266 14.2743 9.48679 12.986C9.79275 12.3741 10.0504 11.6156 10.2293 10.75H5.77067C5.94959 11.6156 6.20725 12.3741 6.51321 12.986C7.15734 14.2743 7.76967 14.5 8 14.5ZM5.55361 9.25C5.51859 8.84716 5.5 8.42956 5.5 8C5.5 7.57044 5.51859 7.15284 5.55361 6.75H10.4464C10.4814 7.15284 10.5 7.57044 10.5 8C10.5 8.42956 10.4814 8.84716 10.4464 9.25H5.55361ZM11.7574 10.75C11.5334 11.974 11.1641 13.0579 10.6914 13.9184C12.0984 13.2775 13.2369 12.1496 13.8913 10.75H11.7574ZM14.3799 9.25H11.9515C11.9834 8.84271 12 8.42523 12 8C12 7.57477 11.9834 7.15729 11.9515 6.75H14.3799C14.4587 7.15451 14.5 7.57243 14.5 8C14.5 8.42756 14.4587 8.84549 14.3799 9.25ZM4.04854 9.25H1.62008C1.54128 8.84549 1.5 8.42756 1.5 8C1.5 7.57243 1.54128 7.15451 1.62008 6.75H4.04854C4.01659 7.15729 4 7.57477 4 8C4 8.42523 4.01659 8.84271 4.04854 9.25ZM2.10868 10.75H4.2426C4.46661 11.974 4.83588 13.0579 5.30864 13.9184C3.90156 13.2775 2.7631 12.1496 2.10868 10.75ZM5.77067 5.25H10.2293C10.0504 4.38438 9.79275 3.6259 9.48679 3.01397C8.84266 1.72571 8.23033 1.5 8 1.5C7.76967 1.5 7.15734 1.72571 6.51321 3.01397C6.20725 3.6259 5.94959 4.38438 5.77067 5.25ZM11.7574 5.25H13.8913C13.2369 3.85044 12.0984 2.72251 10.6914 2.08162C11.1641 2.94207 11.5334 4.02603 11.7574 5.25ZM5.30864 2.08162C4.83588 2.94207 4.46661 4.02603 4.2426 5.25H2.10868C2.7631 3.85044 3.90156 2.72251 5.30864 2.08162ZM8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.06301 2.75L13.2511 9.93813L11.4539 11.7354C10.9854 12.2227 10.4243 12.6116 9.80367 12.8795C9.183 13.1473 8.51514 13.2887 7.83918 13.2953C7.16321 13.302 6.49271 13.1737 5.86691 12.9181C5.2411 12.6625 4.67257 12.2846 4.19456 11.8066C3.71656 11.3286 3.33869 10.76 3.08306 10.1342C2.82743 9.50843 2.69918 8.83793 2.70581 8.16196C2.71244 7.486 2.85382 6.81814 3.12167 6.19747C3.38953 5.5768 3.77848 5.01579 4.26576 4.54725L6.06301 2.75Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1 15L4.0625 11.9375"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.625 1L7.5625 4.0625"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 5.375L11.9375 8.4375"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6 1C5.0335 1 4.25 1.7835 4.25 2.75V4H3C1.89543 4 1 4.89543 1 6V13C1 14.1046 1.89543 15 3 15H13C14.1046 15 15 14.1046 15 13V6C15 4.89543 14.1046 4 13 4H11.75V2.75C11.75 1.7835 10.9665 1 10 1H6ZM10.25 4V2.75C10.25 2.61193 10.1381 2.5 10 2.5H6C5.86193 2.5 5.75 2.61193 5.75 2.75V4H10.25ZM3 5.5H13C13.2761 5.5 13.5 5.72386 13.5 6V7H2.5V6C2.5 5.72386 2.72386 5.5 3 5.5ZM2.5 8.5V13C2.5 13.2761 2.72386 13.5 3 13.5H13C13.2761 13.5 13.5 13.2761 13.5 13V8.5H9V10H7V8.5H2.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.8335 7.83337H7.00016C6.55814 7.83337 6.13421 8.00897 5.82165 8.32153C5.50909 8.63409 5.3335 9.05801 5.3335 9.50004V17C5.3335 17.4421 5.50909 17.866 5.82165 18.1786C6.13421 18.4911 6.55814 18.6667 7.00016 18.6667H14.5002C14.9422 18.6667 15.3661 18.4911 15.6787 18.1786C15.9912 17.866 16.1668 17.4421 16.1668 17V16.1667"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.9875 7.48759C19.3157 7.15938 19.5001 6.71424 19.5001 6.25009C19.5001 5.78594 19.3157 5.34079 18.9875 5.01259C18.6593 4.68438 18.2142 4.5 17.75 4.5C17.2858 4.5 16.8407 4.68438 16.5125 5.01259L9.5 12.0001V14.5001H12L18.9875 7.48759Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M15.3335 6.16663L17.8335 8.66663"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3 2.5C2.17157 2.5 1.5 3.17157 1.5 4V10C1.5 10.8284 2.17157 11.5 3 11.5H3.75H4.5V12.25V13.1004L6.56675 11.6378L6.76146 11.5H7H13C13.8284 11.5 14.5 10.8284 14.5 10V4C14.5 3.17157 13.8284 2.5 13 2.5H3ZM0 4C0 2.34315 1.34315 1 3 1H13C14.6569 1 16 2.34315 16 4V10C16 11.6569 14.6569 13 13 13H7.23854L4.18325 15.1622L3 15.9996V14.55V13C1.34315 13 0 11.6569 0 10V4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M8 3V13M3 8H13" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3 2.5H5C5.27614 2.5 5.5 2.72386 5.5 3V5C5.5 5.27614 5.27614 5.5 5 5.5H3C2.72386 5.5 2.5 5.27614 2.5 5V3C2.5 2.72386 2.72386 2.5 3 2.5ZM1 3C1 1.89543 1.89543 1 3 1H5C6.10457 1 7 1.89543 7 3V5C7 6.10457 6.10457 7 5 7H3C1.89543 7 1 6.10457 1 5V3ZM3 10.5H5C5.27614 10.5 5.5 10.7239 5.5 11V13C5.5 13.2761 5.27614 13.5 5 13.5H3C2.72386 13.5 2.5 13.2761 2.5 13V11C2.5 10.7239 2.72386 10.5 3 10.5ZM1 11C1 9.89543 1.89543 9 3 9H5C6.10457 9 7 9.89543 7 11V13C7 14.1046 6.10457 15 5 15H3C1.89543 15 1 14.1046 1 13V11ZM13 2.5H11C10.7239 2.5 10.5 2.72386 10.5 3V5C10.5 5.27614 10.7239 5.5 11 5.5H13C13.2761 5.5 13.5 5.27614 13.5 5V3C13.5 2.72386 13.2761 2.5 13 2.5ZM11 1C9.89543 1 9 1.89543 9 3V5C9 6.10457 9.89543 7 11 7H13C14.1046 7 15 6.10457 15 5V3C15 1.89543 14.1046 1 13 1H11ZM11 10.5H13C13.2761 10.5 13.5 10.7239 13.5 11V13C13.5 13.2761 13.2761 13.5 13 13.5H11C10.7239 13.5 10.5 13.2761 10.5 13V11C10.5 10.7239 10.7239 10.5 11 10.5ZM9 11C9 9.89543 9.89543 9 11 9H13C14.1046 9 15 9.89543 15 11V13C15 14.1046 14.1046 15 13 15H11C9.89543 15 9 14.1046 9 13V11Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 7H4C2.89543 7 2 6.10457 2 5C2 3.89543 2.896 3 4.00057 3H11.9994C13.104 3 14 3.89543 14 5C14 6.10457 13.1046 7 12 7ZM5 5C5 5.55228 4.55228 6 4 6C3.44772 6 3 5.55228 3 5C3 4.44772 3.44772 4 4 4C4.55228 4 5 4.44772 5 5ZM2 12V10C2 8.89543 2.896 8 4.00057 8H11.9994C13.104 8 14 8.89543 14 10V12C14 13.1046 13.1046 14 12 14H4C2.89543 14 2 13.1046 2 12ZM5 10C5 10.5523 4.55228 11 4 11C3.44772 11 3 10.5523 3 10C3 9.44772 3.44772 9 4 9C4.55228 9 5 9.44772 5 10Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5 15V1H6.5V15H5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M10.9002 8.28183C11.0333 8.1154 11.0333 7.8846 10.9002 7.71817L8.87389 5.18369C8.59132 4.83026 8 5.02096 8 5.46552V10.5345C8 10.979 8.59132 11.1697 8.87389 10.8163L10.9002 8.28183Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="0.75"
|
||||
y="0.75"
|
||||
width="14.5"
|
||||
height="14.5"
|
||||
rx="3.25"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11 15V1H9.5V15H11Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5.0998 8.28183C4.96673 8.1154 4.96673 7.8846 5.0998 7.71817L7.12611 5.18369C7.40868 4.83026 8 5.02096 8 5.46552L8 10.5345C8 10.979 7.40868 11.1697 7.12611 10.8163L5.0998 8.28183Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect
|
||||
x="-0.75"
|
||||
y="0.75"
|
||||
width="14.5"
|
||||
height="14.5"
|
||||
rx="3.25"
|
||||
transform="matrix(-1 0 0 1 14.5 0)"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.64645 8.35355C9.84171 8.15829 9.84171 7.84171 9.64645 7.64645L6.85355 4.85355C6.53857 4.53857 6 4.76165 6 5.20711V10.7929C6 11.2383 6.53857 11.4614 6.85355 11.1464L9.64645 8.35355Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d="M4.00065 8.66667C2.53398 8.66667 1.33398 9.86667 1.33398 11.3333C1.33398 12.8 2.53398 14 4.00065 14C5.46732 14 6.66732 12.8 6.66732 11.3333C6.66732 9.86667 5.46732 8.66667 4.00065 8.66667ZM8.00065 2C6.53398 2 5.33398 3.2 5.33398 4.66667C5.33398 6.13333 6.53398 7.33333 8.00065 7.33333C9.46732 7.33333 10.6673 6.13333 10.6673 4.66667C10.6673 3.2 9.46732 2 8.00065 2ZM12.0007 8.66667C10.534 8.66667 9.33398 9.86667 9.33398 11.3333C9.33398 12.8 10.534 14 12.0007 14C13.4673 14 14.6673 12.8 14.6673 11.3333C14.6673 9.86667 13.4673 8.66667 12.0007 8.66667Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,22 +1,19 @@
|
||||
<template>
|
||||
<div>
|
||||
<nav class="fixed z-40 top-0 h-14 bg-foundation border-b border-outline-2">
|
||||
<nav class="fixed z-40 top-0 h-12 bg-foundation border-b border-outline-2">
|
||||
<div
|
||||
class="flex gap-4 items-center justify-between h-full w-screen py-4 pl-3 pr-4"
|
||||
class="flex gap-4 items-center justify-between h-full w-screen py-4 px-3 sm:px-4"
|
||||
>
|
||||
<HeaderLogoBlock :active="false" to="/" class="hidden lg:flex lg:min-w-40" />
|
||||
<div class="flex items-center truncate">
|
||||
<HeaderLogoBlock :active="false" to="/" />
|
||||
<HeaderNavLink
|
||||
to="/"
|
||||
name="Dashboard"
|
||||
:separator="true"
|
||||
class="hidden md:inline-block"
|
||||
/>
|
||||
<ClientOnly>
|
||||
<PortalTarget name="mobile-navigation"></PortalTarget>
|
||||
</ClientOnly>
|
||||
<ClientOnly>
|
||||
<PortalTarget name="navigation"></PortalTarget>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 sm:gap-2">
|
||||
<div class="flex items-center justify-end gap-2.5 sm:gap-2 lg:min-w-40">
|
||||
<ClientOnly>
|
||||
<PortalTarget name="secondary-actions"></PortalTarget>
|
||||
<PortalTarget name="primary-actions"></PortalTarget>
|
||||
@@ -35,9 +32,8 @@
|
||||
<HeaderNavUserMenu :login-url="loginUrl" />
|
||||
</div>
|
||||
</div>
|
||||
<PopupsSignIn v-if="!activeUser" />
|
||||
</nav>
|
||||
<div class="h-16"></div>
|
||||
<PopupsSignIn v-if="!activeUser" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
<template>
|
||||
<div class="text-foreground hover:text-primary-focus transition last:truncate">
|
||||
<div
|
||||
class="text-foreground hover:text-primary-focus transition truncate flex gap-1 items-center ml-1"
|
||||
>
|
||||
<div v-if="separator">
|
||||
<svg
|
||||
width="8"
|
||||
height="24"
|
||||
viewBox="0 0 8 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-outline-2"
|
||||
>
|
||||
<path d="M2 18L6 6" stroke="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<NuxtLink
|
||||
:to="to"
|
||||
class="flex gap-1 items-center text-body-sm ml-0.5"
|
||||
active-class="text-primary text-heading group is-active"
|
||||
:to="disableLink ? undefined : to"
|
||||
class="flex gap-1 items-center text-body-xs ml-0.5 text-foreground-2 select-none truncate font-medium"
|
||||
:class="disableLink ? '' : 'hover:!text-foreground'"
|
||||
active-class="group is-active !text-foreground"
|
||||
>
|
||||
<div v-if="separator">
|
||||
<ChevronRightIcon class="flex w-4 h-4 text-foreground-2" />
|
||||
</div>
|
||||
<div class="group-[.is-active]:truncate">
|
||||
<div class="truncate">
|
||||
{{ name || to }}
|
||||
</div>
|
||||
<!-- Chevron to return in future -->
|
||||
<!-- <ChevronDownIcon v-if="!hideChevron" class="h-2.5 w-2.5" /> -->
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@heroicons/vue/20/solid'
|
||||
defineProps({
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
hideChevron: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '/'
|
||||
@@ -28,6 +45,10 @@ defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
disableLink: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,22 +2,23 @@
|
||||
<div>
|
||||
<Menu as="div" class="flex items-center">
|
||||
<MenuButton :id="menuButtonId" v-slot="{ open: menuOpen }" as="div">
|
||||
<div class="cursor-pointer">
|
||||
<div
|
||||
class="relative cursor-pointer p-1 w-8 h-8 flex items-center justify-center rounded-md"
|
||||
:class="menuOpen ? 'border border-outline-2' : ''"
|
||||
>
|
||||
<span class="sr-only">Open notifications menu</span>
|
||||
<div class="relative">
|
||||
<div v-if="!menuOpen" class="scale-75">
|
||||
<div v-if="!menuOpen">
|
||||
<div
|
||||
class="absolute top-1 right-1 w-3 h-3 rounded-full bg-primary animate-ping"
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary animate-ping"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -top-1 right-0 w-1.5 h-1.5 rounded-full bg-primary"
|
||||
></div>
|
||||
<div class="absolute top-1 right-1 w-3 h-3 rounded-full bg-primary"></div>
|
||||
</div>
|
||||
|
||||
<UserAvatar v-if="!menuOpen" no-bg size="lg" hover-effect>
|
||||
<BellIcon class="text-primary sm:text-foreground w-5 h-5" />
|
||||
</UserAvatar>
|
||||
<UserAvatar v-else size="lg" hover-effect no-bg>
|
||||
<XMarkIcon class="text-primary sm:text-foreground w-5 h-5" />
|
||||
</UserAvatar>
|
||||
<BellIcon v-if="!menuOpen" class="w-5 h-5" />
|
||||
<XMarkIcon v-else class="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</MenuButton>
|
||||
@@ -30,9 +31,9 @@
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
class="absolute z-50 right-0 md:right-16 top-14 sm:top-16 w-full sm:w-64 origin-top-right bg-foundation outline outline-2 outline-primary-muted rounded-md shadow-lg overflow-hidden"
|
||||
class="absolute z-50 right-0 md:right-20 top-10 mt-1.5 w-full sm:w-64 origin-top-right bg-foundation-page outline outline-2 outline-primary-muted rounded-md shadow-lg overflow-hidden"
|
||||
>
|
||||
<div class="p-2 text-heading-sm bg-foundation-2">Notifications</div>
|
||||
<div class="px-3 py-2 text-body-xs font-medium">Notifications</div>
|
||||
<!-- <div class="p-2 text-sm">TODO: project invites</div> -->
|
||||
<MenuItem>
|
||||
<AuthVerificationReminderMenuNotice />
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<template>
|
||||
<Menu
|
||||
as="div"
|
||||
class="flex items-center relative sm:border-r border-outline-3 sm:pr-4"
|
||||
>
|
||||
<Menu as="div" class="flex items-center relative">
|
||||
<MenuButton :id="menuButtonId" as="div">
|
||||
<!-- Desktop Button -->
|
||||
<FormButton class="hidden sm:flex" :icon-right="ChevronDownIcon">
|
||||
Share
|
||||
</FormButton>
|
||||
<button class="sm:hidden mt-1.5">
|
||||
<ShareIcon class="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
<!-- Mobile Button -->
|
||||
<FormButton
|
||||
color="subtle"
|
||||
size="sm"
|
||||
class="sm:hidden"
|
||||
:icon-right="ShareIcon"
|
||||
hide-text
|
||||
>
|
||||
Share
|
||||
</FormButton>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<Menu as="div" class="flex items-center">
|
||||
<MenuButton :id="menuButtonId" v-slot="{ open: userOpen }">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<UserAvatar v-if="!userOpen" size="lg" :user="activeUser" hover-effect />
|
||||
<UserAvatar v-else size="lg" hover-effect>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
</UserAvatar>
|
||||
<div class="flex items-center gap-1 p-0.5 hover:bg-highlight-2 rounded">
|
||||
<UserAvatar hide-tooltip :user="activeUser" />
|
||||
<ChevronDownIcon :class="userOpen ? 'rotate-180' : ''" class="h-3 w-3" />
|
||||
</div>
|
||||
</MenuButton>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
@@ -24,11 +24,11 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-primary cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-primary cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
target="_blank"
|
||||
external
|
||||
:href="connectorsPageUrl"
|
||||
:href="downloadManagerUrl"
|
||||
>
|
||||
Connector downloads
|
||||
</NuxtLink>
|
||||
@@ -38,9 +38,9 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="toggleSettingsDialog(settingsQueries.user.profile)"
|
||||
@click="toggleSettingsDialog(SettingMenuKeys.User.Profile)"
|
||||
>
|
||||
Settings
|
||||
</NuxtLink>
|
||||
@@ -49,9 +49,9 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="toggleSettingsDialog(settingsQueries.server.general)"
|
||||
@click="toggleSettingsDialog(SettingMenuKeys.Server.General)"
|
||||
>
|
||||
Server settings
|
||||
</NuxtLink>
|
||||
@@ -60,7 +60,7 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
@@ -71,7 +71,7 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="toggleInviteDialog"
|
||||
>
|
||||
@@ -82,11 +82,10 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
target="_blank"
|
||||
to="https://docs.google.com/forms/d/e/1FAIpQLSeTOU8i0KwpgBG7ONimsh4YMqvLKZfSRhWEOz4W0MyjQ1lfAQ/viewform"
|
||||
external
|
||||
class="text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded"
|
||||
@click="openFeedbackDialog"
|
||||
>
|
||||
Feedback
|
||||
</NuxtLink>
|
||||
@@ -96,7 +95,7 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'text-body-sm flex px-2 py-1.5 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'text-body-xs flex px-2 py-1 text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
@click="logout"
|
||||
>
|
||||
@@ -107,7 +106,7 @@
|
||||
<NuxtLink
|
||||
:class="[
|
||||
active ? 'bg-highlight-1' : '',
|
||||
'flex px-2 py-1.5 text-sm text-foreground cursor-pointer transition mx-1 rounded'
|
||||
'flex px-2 py-1 text-body-xs text-foreground cursor-pointer transition mx-1 rounded'
|
||||
]"
|
||||
:to="loginUrl"
|
||||
>
|
||||
@@ -129,23 +128,29 @@
|
||||
<SettingsDialog
|
||||
v-model:open="showSettingsDialog"
|
||||
v-model:target-menu-item="settingsDialogTarget"
|
||||
v-model:target-workspace-id="workspaceSettingsDialogTarget"
|
||||
/>
|
||||
<FeedbackDialog v-model:open="showFeedbackDialog" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isString } from 'lodash'
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { useAuthManager } from '~~/lib/auth/composables/auth'
|
||||
import { useTheme } from '~~/lib/core/composables/theme'
|
||||
import { connectorsPageUrl, settingsQueries } from '~/lib/common/helpers/route'
|
||||
import { downloadManagerUrl } from '~/lib/common/helpers/route'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
||||
import { useServerInfo } from '~/lib/core/composables/server'
|
||||
import {
|
||||
SettingMenuKeys,
|
||||
type AvailableSettingsMenuKeys
|
||||
} from '~/lib/settings/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
loginUrl?: RouteLocationRaw
|
||||
@@ -158,13 +163,15 @@ const { isDarkTheme, toggleTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const { triggerNotification } = useGlobalToast()
|
||||
const { serverInfo } = useServerInfo()
|
||||
const breakpoints = useBreakpoints(TailwindBreakpoints)
|
||||
|
||||
const showInviteDialog = ref(false)
|
||||
const showSettingsDialog = ref(false)
|
||||
const settingsDialogTarget = ref<string | null>(null)
|
||||
const workspaceSettingsDialogTarget = ref<string | null>(null)
|
||||
const menuButtonId = useId()
|
||||
const breakpoints = useBreakpoints(TailwindBreakpoints)
|
||||
const isMobile = breakpoints.smaller('md')
|
||||
const showFeedbackDialog = ref(false)
|
||||
|
||||
const version = computed(() => serverInfo.value?.version)
|
||||
const isAdmin = computed(() => activeUser.value?.role === Roles.Server.Admin)
|
||||
@@ -173,7 +180,7 @@ const toggleInviteDialog = () => {
|
||||
showInviteDialog.value = true
|
||||
}
|
||||
|
||||
const toggleSettingsDialog = (target: string) => {
|
||||
const toggleSettingsDialog = (target: AvailableSettingsMenuKeys) => {
|
||||
showSettingsDialog.value = true
|
||||
|
||||
// On mobile open the modal but dont set the target
|
||||
@@ -183,11 +190,20 @@ const toggleSettingsDialog = (target: string) => {
|
||||
const deleteSettingsQuery = (): void => {
|
||||
const currentQueryParams = { ...route.query }
|
||||
delete currentQueryParams.settings
|
||||
delete currentQueryParams.workspace
|
||||
delete currentQueryParams.error
|
||||
|
||||
router.push({ query: currentQueryParams })
|
||||
}
|
||||
|
||||
const openFeedbackDialog = () => {
|
||||
showFeedbackDialog.value = true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const settingsQuery = route.query?.settings
|
||||
const workspaceQuery = route.query?.workspace
|
||||
const errorQuery = route.query?.error
|
||||
|
||||
if (settingsQuery && isString(settingsQuery)) {
|
||||
if (settingsQuery.includes('server') && !isAdmin.value) {
|
||||
@@ -199,6 +215,22 @@ onMounted(() => {
|
||||
return
|
||||
}
|
||||
|
||||
if (workspaceQuery && isString(workspaceQuery)) {
|
||||
workspaceSettingsDialogTarget.value = workspaceQuery
|
||||
|
||||
if (errorQuery && isString(errorQuery)) {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Danger,
|
||||
title: errorQuery
|
||||
})
|
||||
} else {
|
||||
triggerNotification({
|
||||
type: ToastNotificationType.Success,
|
||||
title: 'SSO settings successfully updated'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
showSettingsDialog.value = true
|
||||
settingsDialogTarget.value = settingsQuery
|
||||
deleteSettingsQuery()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div :class="mainClasses">
|
||||
<div :class="mainInfoBlockClasses">
|
||||
<UserAvatar v-if="invite.invitedBy" :user="invite.invitedBy" :size="avatarSize" />
|
||||
<WorkspaceAvatar
|
||||
v-if="invite.workspace"
|
||||
:logo="invite.workspace.logo"
|
||||
:default-logo-index="invite.workspace.defaultLogoIndex"
|
||||
/>
|
||||
<div class="text-foreground">
|
||||
<slot name="message" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 w-full sm:w-auto shrink-0">
|
||||
<div v-if="isLoggedIn" class="flex items-center justify-end w-full space-x-2">
|
||||
<FormButton
|
||||
:size="buttonSize"
|
||||
color="subtle"
|
||||
text
|
||||
:full-width="block"
|
||||
:disabled="loading"
|
||||
@click="onDeclineClick(token)"
|
||||
>
|
||||
{{ declineMessage }}
|
||||
</FormButton>
|
||||
<FormButton
|
||||
:full-width="block"
|
||||
:size="buttonSize"
|
||||
color="outline"
|
||||
class="px-4"
|
||||
:icon-left="CheckIcon"
|
||||
:disabled="loading"
|
||||
@click="onAcceptClick(token)"
|
||||
>
|
||||
{{ acceptMessage }}
|
||||
</FormButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<FormButton
|
||||
:size="buttonSize"
|
||||
color="outline"
|
||||
full-width
|
||||
:disabled="loading"
|
||||
@click.stop.prevent="onLoginSignupClick"
|
||||
>
|
||||
{{ isForRegisteredUser ? 'Log in' : 'Sign up' }}
|
||||
</FormButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { MaybeNullOrUndefined, Optional } from '@speckle/shared'
|
||||
import type { AvatarUserType } from '~/lib/user/composables/avatar'
|
||||
import { CheckIcon } from '@heroicons/vue/24/solid'
|
||||
import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect'
|
||||
import {
|
||||
useNavigateToLogin,
|
||||
useNavigateToRegistration
|
||||
} from '~/lib/common/helpers/route'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
|
||||
const emit = defineEmits<{
|
||||
processed: [accept: boolean, token: Optional<string>]
|
||||
}>()
|
||||
|
||||
type GenericInviteItem = {
|
||||
invitedBy?: AvatarUserType
|
||||
workspace?: {
|
||||
id: string
|
||||
logo?: string
|
||||
defaultLogoIndex: number
|
||||
}
|
||||
user?: MaybeNullOrUndefined<{
|
||||
id: string
|
||||
}>
|
||||
token?: MaybeNullOrUndefined<string>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
invite: GenericInviteItem
|
||||
/**
|
||||
* Render this as a big block, instead of a small row. Used in full-page project access error pages.
|
||||
*/
|
||||
block?: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const postAuthRedirect = usePostAuthRedirect()
|
||||
const goToLogin = useNavigateToLogin()
|
||||
const goToSignUp = useNavigateToRegistration()
|
||||
const mixpanel = useMixpanel()
|
||||
|
||||
const token = computed(
|
||||
() => props.invite?.token || (route.query.token as Optional<string>)
|
||||
)
|
||||
const mainClasses = computed(() => {
|
||||
const classParts = [
|
||||
'flex flex-col space-y-4 px-4 py-5 transition border-x border-b border-outline-2 first:border-t first:rounded-t-lg last:rounded-b-lg'
|
||||
]
|
||||
|
||||
if (props.block) {
|
||||
classParts.push('')
|
||||
} else {
|
||||
classParts.push('sm:space-y-0 sm:space-x-2 sm:items-center sm:flex-row sm:py-2')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const mainInfoBlockClasses = computed(() => {
|
||||
const classParts = ['flex grow items-center']
|
||||
|
||||
if (props.block) {
|
||||
classParts.push('flex-col space-y-2')
|
||||
} else {
|
||||
classParts.push('flex-row space-x-2 text-body-xs')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
})
|
||||
|
||||
const avatarSize = computed(() => (props.block ? 'xxl' : 'base'))
|
||||
const buttonSize = computed(() => (props.block ? 'lg' : 'sm'))
|
||||
const isForRegisteredUser = computed(() => !!props.invite.user?.id)
|
||||
const acceptMessage = computed(() => (props.invite.workspace ? 'Join' : 'Accept'))
|
||||
const declineMessage = computed(() => (props.invite.workspace ? 'Dismiss' : 'Decline'))
|
||||
|
||||
const onLoginSignupClick = async () => {
|
||||
postAuthRedirect.setCurrentRoute()
|
||||
const query = {
|
||||
token: token.value || undefined
|
||||
}
|
||||
|
||||
if (isForRegisteredUser.value) {
|
||||
await goToLogin({
|
||||
query
|
||||
})
|
||||
} else {
|
||||
await goToSignUp({ query })
|
||||
}
|
||||
}
|
||||
|
||||
const onDeclineClick = (token?: string) => {
|
||||
emit('processed', false, token)
|
||||
if (props.invite.workspace) {
|
||||
mixpanel.track('Invite Action', {
|
||||
accepted: false,
|
||||
type: 'workspace invite',
|
||||
location: 'invite banner',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.invite.workspace.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onAcceptClick = (token?: string) => {
|
||||
emit('processed', true, token)
|
||||
if (props.invite.workspace) {
|
||||
mixpanel.track('Workspace Joined', {
|
||||
location: 'invite banner',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.invite.workspace.id
|
||||
})
|
||||
|
||||
mixpanel.track('Invite Action', {
|
||||
accepted: true,
|
||||
type: 'workspace invite',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.invite.workspace.id
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<span v-else class="text-body-xs text-foreground-2 text-center select-none">
|
||||
Use our
|
||||
<NuxtLink target="_blank" :to="connectorsPageUrl" class="font-semibold">
|
||||
<NuxtLink target="_blank" :to="downloadManagerUrl" class="font-medium">
|
||||
connectors
|
||||
</NuxtLink>
|
||||
to publish a {{ modelName ? '' : 'new model' }} version to
|
||||
@@ -48,7 +48,7 @@
|
||||
import { useFileImport } from '~~/lib/core/composables/fileImport'
|
||||
import { useFileUploadProgressCore } from '~~/lib/form/composables/fileUpload'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid'
|
||||
import { connectorsPageUrl } from '~/lib/common/helpers/route'
|
||||
import { downloadManagerUrl } from '~/lib/common/helpers/route'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center max-w-[180px] mx-auto">
|
||||
<div
|
||||
class="relative text-foreground-2 h-32 w-full"
|
||||
:class="small ? 'scale-75 -mb-2' : 'mb-1'"
|
||||
>
|
||||
<slot name="image"></slot>
|
||||
<div class="flex justify-center flex-col text-center my-12">
|
||||
<h3 class="text-heading mt-2 text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<h4 v-if="text" class="text-body-xs mb-4 mt-2 max-w-xs mx-auto text-foreground-2">
|
||||
{{ text }}
|
||||
</h4>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<slot name="cta"></slot>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 items-center text-center">
|
||||
<span class="text-foreground text-heading">{{ title }}</span>
|
||||
<span v-if="text" class="text-xs text-foreground-2">
|
||||
{{ text }}
|
||||
</span>
|
||||
</div>
|
||||
<slot name="cta"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
small?: boolean
|
||||
title: string
|
||||
text?: string
|
||||
}>()
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
].includes(upload.convertedStatus)
|
||||
"
|
||||
>
|
||||
<span>{{ isSelfImport ? 'Importing' : 'Uploading new version' }}</span>
|
||||
<span class="text-body-xs mb-1">
|
||||
{{ isSelfImport ? 'Importing' : 'Uploading new version' }}
|
||||
</span>
|
||||
<CommonLoadingBar loading class="max-w-[100px]" />
|
||||
</template>
|
||||
<template
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<HeaderNavLink
|
||||
:to="projectRoute(project.id)"
|
||||
:name="project.name"
|
||||
></HeaderNavLink>
|
||||
v-if="project.workspace && isWorkspacesEnabled"
|
||||
:to="workspaceRoute(project.workspace.slug)"
|
||||
:name="project.workspace.name"
|
||||
:separator="false"
|
||||
/>
|
||||
<HeaderNavLink v-else :to="projectsRoute" name="Projects" :separator="false" />
|
||||
<HeaderNavLink :to="projectRoute(project.id)" :name="project.name" />
|
||||
<HeaderNavLink
|
||||
v-if="props.project.model"
|
||||
:to="modelVersionsRoute(project.id, props.project.model.id)"
|
||||
:name="props.project.model.name"
|
||||
></HeaderNavLink>
|
||||
/>
|
||||
</Portal>
|
||||
|
||||
<CommonTitleDescription
|
||||
@@ -22,7 +26,12 @@
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { ProjectModelPageHeaderProjectFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { projectRoute, modelVersionsRoute } from '~~/lib/common/helpers/route'
|
||||
import {
|
||||
projectRoute,
|
||||
modelVersionsRoute,
|
||||
projectsRoute
|
||||
} from '~~/lib/common/helpers/route'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectModelPageHeaderProject on Project {
|
||||
@@ -33,10 +42,17 @@ graphql(`
|
||||
name
|
||||
description
|
||||
}
|
||||
workspace {
|
||||
id
|
||||
slug
|
||||
name
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const props = defineProps<{
|
||||
project: ProjectModelPageHeaderProjectFragment
|
||||
}>()
|
||||
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
max-width="sm"
|
||||
max-width="xs"
|
||||
:buttons="[
|
||||
{
|
||||
text: 'Delete',
|
||||
props: { color: 'danger', fullWidth: true, disabled: loading },
|
||||
props: { color: 'danger', disabled: loading },
|
||||
onClick: () => {
|
||||
onDelete()
|
||||
}
|
||||
@@ -18,14 +18,15 @@
|
||||
</template>
|
||||
<div class="flex flex-col text-foreground">
|
||||
<p>
|
||||
Deleting versions is an irrevocable action! If you are sure about wanting to
|
||||
delete
|
||||
Are you sure you want to delete
|
||||
<template v-if="versions.length > 1">the selected versions,</template>
|
||||
<template v-else-if="versions.length">
|
||||
the selected version
|
||||
<span class="inline font-medium">"{{ versions[0].message }}",</span>
|
||||
<span v-if="versions[0].message" class="inline font-medium">
|
||||
"{{ versions[0].message }}"
|
||||
</span>
|
||||
</template>
|
||||
please click on the button below!
|
||||
?
|
||||
</p>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
max-width="sm"
|
||||
max-width="xs"
|
||||
:buttons="[
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
props: { fullWidth: true, disabled: loading },
|
||||
props: { disabled: loading },
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
}
|
||||
@@ -20,14 +20,14 @@
|
||||
]"
|
||||
@fully-closed="$emit('fully-closed')"
|
||||
>
|
||||
<template #header>Edit Version Message</template>
|
||||
<form class="flex flex-col text-foreground space-y-4" @submit="onSubmit">
|
||||
<FormTextInput
|
||||
<template #header>Edit version message</template>
|
||||
<form class="flex flex-col text-foreground space-y-4 mb-2" @submit="onSubmit">
|
||||
<FormTextArea
|
||||
v-model="message"
|
||||
name="newMessage"
|
||||
label="Version message"
|
||||
placeholder="Version message"
|
||||
show-required
|
||||
show-label
|
||||
color="foundation"
|
||||
:rules="[isRequired]"
|
||||
:disabled="loading"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
max-width="sm"
|
||||
max-width="xs"
|
||||
@fully-closed="$emit('fully-closed')"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<LayoutDialog
|
||||
v-model:open="isOpen"
|
||||
max-width="md"
|
||||
max-width="lg"
|
||||
:buttons="isPrivate ? nonDiscoverableButtons : discoverableButtons"
|
||||
>
|
||||
<template v-if="isPrivate" #header>Change access permissions</template>
|
||||
@@ -196,14 +196,14 @@ const isPrivate = computed(() => {
|
||||
const discoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Copy embed code',
|
||||
props: { fullWidth: true },
|
||||
props: {},
|
||||
onClick: () => {
|
||||
handleEmbedCodeCopy(iframeCode.value)
|
||||
}
|
||||
@@ -213,7 +213,7 @@ const discoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
const nonDiscoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Close',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
@@ -221,7 +221,6 @@ const nonDiscoverableButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Save',
|
||||
props: {
|
||||
fullWidth: true,
|
||||
disabled: projectVisibility.value === props.project.visibility
|
||||
},
|
||||
onClick: saveProjectVisibility
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<form @submit="onSubmit">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<div class="">
|
||||
Please select the target branch to move
|
||||
<template v-if="versions.length > 1">all of the selected versions</template>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="group rounded-xl bg-foundation border border-outline-3 hover:border-outline-5"
|
||||
@mouseleave="showActionsMenu = false"
|
||||
>
|
||||
<div class="flex flex-col p-3 pt-2" @click="$emit('click', $event)">
|
||||
<div class="flex justify-between items-center">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<LayoutMenu
|
||||
v-model:open="showActionsMenu"
|
||||
:items="actionsItems"
|
||||
:menu-position="HorizontalDirection.Left"
|
||||
@click.stop.prevent
|
||||
@chosen="onActionChosen"
|
||||
>
|
||||
@@ -22,6 +23,7 @@ import { EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
|
||||
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
|
||||
import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
|
||||
import { VersionActionTypes } from '~~/lib/projects/helpers/components'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', v: boolean): void
|
||||
|
||||
@@ -51,9 +51,9 @@ const classes = computed(() => {
|
||||
if (props.vertical) {
|
||||
classParts.push('grid-cols-1')
|
||||
} else if (props.smallView) {
|
||||
classParts.push('grid-cols-1 sm:grid-cols-2 lg:grid-cols-3')
|
||||
classParts.push('grid-cols-1 sm:grid-cols-2')
|
||||
} else {
|
||||
classParts.push('grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4')
|
||||
classParts.push('grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4')
|
||||
}
|
||||
|
||||
return classParts.join(' ')
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<Portal to="navigation">
|
||||
<template v-if="project.workspace && isWorkspacesEnabled">
|
||||
<HeaderNavLink
|
||||
:to="workspaceRoute(project.workspace.slug)"
|
||||
:name="project.workspace.name"
|
||||
:separator="false"
|
||||
></HeaderNavLink>
|
||||
</template>
|
||||
<HeaderNavLink
|
||||
v-else
|
||||
:to="projectsRoute"
|
||||
name="Projects"
|
||||
:separator="false"
|
||||
></HeaderNavLink>
|
||||
|
||||
<HeaderNavLink
|
||||
:to="projectRoute(project.id)"
|
||||
:name="project.name"
|
||||
></HeaderNavLink>
|
||||
</Portal>
|
||||
<CommonTitleDescription :title="project.name" :description="project.description" />
|
||||
|
||||
<div class="flex gap-x-3">
|
||||
<NuxtLink
|
||||
v-if="project.workspace && isWorkspacesEnabled"
|
||||
:to="workspaceRoute(project.workspace.slug)"
|
||||
>
|
||||
<WorkspaceAvatar
|
||||
:logo="project.workspace.logo"
|
||||
:default-logo-index="project.workspace.defaultLogoIndex"
|
||||
size="sm"
|
||||
class="mt-0.5"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<CommonTitleDescription
|
||||
:title="project.name"
|
||||
:description="project.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import type { ProjectPageProjectHeaderFragment } from '~~/lib/common/generated/gql/graphql'
|
||||
import { projectRoute } from '~~/lib/common/helpers/route'
|
||||
import { projectRoute, projectsRoute } from '~~/lib/common/helpers/route'
|
||||
import { workspaceRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageProjectHeader on Project {
|
||||
@@ -23,10 +55,18 @@ graphql(`
|
||||
description
|
||||
visibility
|
||||
allowPublicComments
|
||||
workspace {
|
||||
id
|
||||
slug
|
||||
name
|
||||
...WorkspaceAvatar_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
defineProps<{
|
||||
project: ProjectPageProjectHeaderFragment
|
||||
}>()
|
||||
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
</script>
|
||||
|
||||
@@ -1,62 +1,115 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
|
||||
<template #header>Invite to project</template>
|
||||
<div class="flex flex-col my-2">
|
||||
<FormTextInput
|
||||
v-model="search"
|
||||
name="search"
|
||||
size="lg"
|
||||
placeholder="Search by email or username..."
|
||||
:disabled="disabled"
|
||||
:help="disabled ? 'You must be the project owner to invite users' : ''"
|
||||
input-classes="pr-[85px] text-sm"
|
||||
color="foundation"
|
||||
label="Add people"
|
||||
show-label
|
||||
>
|
||||
<template #input-right>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
:class="disabled ? 'pointer-events-none' : ''"
|
||||
>
|
||||
<ProjectPageTeamPermissionSelect v-model="role" hide-remove />
|
||||
</div>
|
||||
</template>
|
||||
</FormTextInput>
|
||||
<div
|
||||
v-if="hasTargets"
|
||||
class="flex flex-col border bg-foundation border-primary-muted mt-2 rounded-md"
|
||||
>
|
||||
<template v-if="searchUsers.length">
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
v-for="user in searchUsers"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
<div class="flex flex-col gap-4 mb-2">
|
||||
<div v-if="!isWorkspaceMemberAndProjectOwner" class="flex flex-col gap-4">
|
||||
<FormSelectWorkspaceRoles
|
||||
v-if="project?.workspaceId"
|
||||
v-model="workspaceRole"
|
||||
show-label
|
||||
label="Workspace role"
|
||||
size="lg"
|
||||
help="If target user does not have a role in the parent workspace, they will be assigned this role."
|
||||
:allow-unset="false"
|
||||
/>
|
||||
<FormTextInput
|
||||
v-model="search"
|
||||
name="search"
|
||||
size="lg"
|
||||
placeholder="Search by email or username..."
|
||||
:disabled="disabled"
|
||||
:help="disabled ? 'You must be the project owner to invite users' : ''"
|
||||
input-classes="pr-[85px] text-sm"
|
||||
color="foundation"
|
||||
label="Add people"
|
||||
show-label
|
||||
>
|
||||
<template #input-right>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-2"
|
||||
:class="disabled ? 'pointer-events-none' : ''"
|
||||
>
|
||||
<ProjectPageTeamPermissionSelect
|
||||
v-model="role"
|
||||
mount-menu-on-body
|
||||
:show-label="false"
|
||||
:disabled-roles="isTargettingWorkspaceGuest ? [Roles.Stream.Owner] : []"
|
||||
:disabled-item-tooltip="
|
||||
isTargettingWorkspaceGuest
|
||||
? 'Workspace guests cannot be project owners'
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FormTextInput>
|
||||
|
||||
<div
|
||||
v-if="hasTargets"
|
||||
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
|
||||
>
|
||||
<template v-if="searchUsers.length">
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
v-for="user in searchUsers"
|
||||
:key="user.id"
|
||||
:user="user"
|
||||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
:target-workspace-role="workspaceRole"
|
||||
@invite-user="($event) => onInviteUser($event.user)"
|
||||
/>
|
||||
</template>
|
||||
<ProjectPageTeamDialogInviteUserEmailsRow
|
||||
v-else-if="selectedEmails?.length"
|
||||
:selected-emails="selectedEmails"
|
||||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
@invite-user="($event) => onInviteUser($event.user)"
|
||||
:is-guest-mode="isGuestMode"
|
||||
:unmatching-domain-policy="unmatchingDomainPolicy"
|
||||
class="p-2"
|
||||
@invite-emails="($event) => onInviteUser($event.emails, $event.serverRole)"
|
||||
/>
|
||||
</template>
|
||||
<ProjectPageTeamDialogInviteUserEmailsRow
|
||||
v-else-if="selectedEmails?.length"
|
||||
:selected-emails="selectedEmails"
|
||||
:stream-role="role"
|
||||
:disabled="loading"
|
||||
:is-guest-mode="isGuestMode"
|
||||
class="p-2"
|
||||
@invite-emails="($event) => onInviteUser($event.emails, $event.serverRole)"
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<FormSelectProjectRoles
|
||||
v-model="role"
|
||||
show-label
|
||||
label="Project role"
|
||||
size="lg"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-body-xs font-medium mb-1">Add users from workspace</div>
|
||||
<div
|
||||
v-if="invitableWorkspaceMembers.length"
|
||||
class="flex flex-col border bg-foundation border-primary-muted rounded-md"
|
||||
>
|
||||
<ProjectPageTeamDialogInviteUserServerUserRow
|
||||
v-for="user in invitableWorkspaceMembers"
|
||||
:key="user.user.id"
|
||||
:user="user.user"
|
||||
:stream-role="role"
|
||||
:disabled="!!(loading || disabledWorkspaceMemberRowMessage(user))"
|
||||
:disabled-message="disabledWorkspaceMemberRowMessage(user)"
|
||||
:target-workspace-role="workspaceRole"
|
||||
@invite-user="($event) => onInviteUser($event.user)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500 mt-4">No available users found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { ServerRoles, StreamRoles } from '@speckle/shared'
|
||||
import type { ServerRoles, StreamRoles, WorkspaceRoles } from '@speckle/shared'
|
||||
import type { UserSearchItem } from '~~/lib/common/composables/users'
|
||||
import type {
|
||||
ProjectInviteCreateInput,
|
||||
ProjectPageInviteDialog_ProjectFragment
|
||||
ProjectPageInviteDialog_ProjectFragment,
|
||||
WorkspaceProjectInviteCreateInput
|
||||
} from '~~/lib/common/generated/gql/graphql'
|
||||
import type { SetFullyRequired } from '~~/lib/common/helpers/type'
|
||||
import { isString } from 'lodash-es'
|
||||
@@ -72,7 +125,34 @@ import { filterInvalidInviteTargets } from '~/lib/workspaces/helpers/invites'
|
||||
graphql(`
|
||||
fragment ProjectPageInviteDialog_Project on Project {
|
||||
id
|
||||
workspaceId
|
||||
workspace {
|
||||
id
|
||||
defaultProjectRole
|
||||
team {
|
||||
items {
|
||||
role
|
||||
user {
|
||||
id
|
||||
name
|
||||
bio
|
||||
company
|
||||
avatar
|
||||
verified
|
||||
role
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...ProjectPageTeamInternals_Project
|
||||
workspace {
|
||||
id
|
||||
domainBasedMembershipProtectionEnabled
|
||||
domains {
|
||||
domain
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
@@ -91,12 +171,32 @@ const projectId = computed(() => props.projectId as string)
|
||||
const projectData = computed(() => props.project)
|
||||
const { collaboratorListItems } = useTeamInternals(projectData)
|
||||
|
||||
const workspaceMembers = computed(() => {
|
||||
return props.project?.workspace?.team?.items || []
|
||||
})
|
||||
|
||||
const invitableWorkspaceMembers = computed(() => {
|
||||
const currentProjectMemberIds = new Set(
|
||||
collaboratorListItems.value.map((item) => item.user?.id)
|
||||
)
|
||||
|
||||
return workspaceMembers.value.filter((member) => {
|
||||
if (!member.user.id || currentProjectMemberIds.has(member.user.id)) return false
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const search = ref('')
|
||||
const role = ref<StreamRoles>(Roles.Stream.Contributor)
|
||||
const workspaceRole = ref<WorkspaceRoles>(Roles.Workspace.Guest)
|
||||
|
||||
const { isGuestMode } = useServerInfo()
|
||||
const createInvite = useInviteUserToProject()
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const {
|
||||
users: searchUsers,
|
||||
emails: selectedEmails,
|
||||
@@ -107,13 +207,26 @@ const {
|
||||
collaboratorListItems.value
|
||||
.filter((i): i is SetFullyRequired<typeof i, 'user'> => !!i.user?.id)
|
||||
.map((t) => t.user.id)
|
||||
)
|
||||
),
|
||||
workspaceId: props.project?.workspaceId
|
||||
})
|
||||
|
||||
const isWorkspaceMemberAndProjectOwner = computed(() => {
|
||||
const userIsWorkspaceMember =
|
||||
workspaceMembers.value.some(
|
||||
(item) =>
|
||||
item.user?.id === activeUser.value?.id && item.role === Roles.Workspace.Member
|
||||
) ?? false
|
||||
|
||||
const userIsProjectOwner = projectData.value?.role === Roles.Stream.Owner
|
||||
|
||||
return userIsWorkspaceMember && userIsProjectOwner
|
||||
})
|
||||
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
@@ -121,6 +234,23 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
])
|
||||
|
||||
const isOwnerSelected = computed(() => role.value === Roles.Stream.Owner)
|
||||
const allowedDomains = computed(() =>
|
||||
props.project?.workspace?.domains?.map((c) => c.domain)
|
||||
)
|
||||
const unmatchingDomainPolicy = computed(() => {
|
||||
if (props.project?.workspace?.domainBasedMembershipProtectionEnabled) {
|
||||
return workspaceRole.value === Roles.Workspace.Guest
|
||||
? false
|
||||
: !selectedEmails.value?.every((email) =>
|
||||
allowedDomains.value?.includes(email.split('@')[1])
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
const isTargettingWorkspaceGuest = computed(
|
||||
() => workspaceRole.value === Roles.Workspace.Guest
|
||||
)
|
||||
|
||||
const onInviteUser = async (
|
||||
user: InvitableUser | InvitableUser[],
|
||||
@@ -132,17 +262,23 @@ const onInviteUser = async (
|
||||
emailTargetServerRole: serverRole
|
||||
})
|
||||
|
||||
const inputs: ProjectInviteCreateInput[] = users.map((u) => ({
|
||||
role: role.value,
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
})
|
||||
}))
|
||||
const inputs: ProjectInviteCreateInput[] | WorkspaceProjectInviteCreateInput[] =
|
||||
users.map((u) => ({
|
||||
role: role.value,
|
||||
...(isString(u)
|
||||
? {
|
||||
email: u,
|
||||
serverRole
|
||||
}
|
||||
: {
|
||||
userId: u.id
|
||||
}),
|
||||
...(props.project?.workspaceId
|
||||
? {
|
||||
workspaceRole: workspaceRole.value
|
||||
}
|
||||
: {})
|
||||
}))
|
||||
if (!inputs.length) return
|
||||
|
||||
const isEmail = !!inputs.find((u) => !!u.email)
|
||||
@@ -162,4 +298,30 @@ const onInviteUser = async (
|
||||
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const disabledWorkspaceMemberRowMessage = (
|
||||
item: (typeof invitableWorkspaceMembers.value)[0]
|
||||
) => {
|
||||
return item.role === Roles.Workspace.Guest && role.value === Roles.Stream.Owner
|
||||
? 'You cannot invite a workspace guest as a project owner.'
|
||||
: undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.project?.workspace?.defaultProjectRole,
|
||||
(newRole, oldRole) => {
|
||||
if (newRole && newRole !== oldRole) {
|
||||
role.value = newRole as StreamRoles
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(workspaceRole, (newRole, oldRole) => {
|
||||
if (newRole === oldRole) return
|
||||
|
||||
if (newRole === Roles.Workspace.Guest && role.value === Roles.Stream.Owner) {
|
||||
role.value = Roles.Stream.Reviewer
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div class="bg-foundation rounded-lg p-4 border border-outline-3">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-foreground-2 mb-2">
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<div class="text-foreground">
|
||||
<slot name="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,36 +4,6 @@
|
||||
title="No discussions, yet."
|
||||
:text="small ? undefined : 'Open a model and start the collaboration today!'"
|
||||
>
|
||||
<template #image>
|
||||
<svg
|
||||
width="170"
|
||||
height="139"
|
||||
viewBox="0 0 170 139"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M134.063 79.625C134.063 80.4879 134.762 81.1875 135.625 81.1875C136.488 81.1875 137.188 80.4879 137.188 79.625C137.188 78.7621 136.488 78.0625 135.625 78.0625C134.762 78.0625 134.063 78.7621 134.063 79.625ZM134.063 79.625H135.625M118.438 79.625C118.438 80.4879 119.137 81.1875 120 81.1875C120.863 81.1875 121.563 80.4879 121.563 79.625C121.563 78.7621 120.863 78.0625 120 78.0625C119.137 78.0625 118.438 78.7621 118.438 79.625ZM118.438 79.625H120M102.813 79.625C102.813 80.4879 103.512 81.1875 104.375 81.1875C105.238 81.1875 105.938 80.4879 105.938 79.625C105.938 78.7621 105.238 78.0625 104.375 78.0625C103.512 78.0625 102.813 78.7621 102.813 79.625ZM102.813 79.625H104.375M160.625 92.1639C160.625 98.8351 155.944 104.642 149.344 105.612C144.818 106.278 140.244 106.792 135.625 107.149V126.5L118.194 109.069C117.332 108.207 116.169 107.718 114.952 107.688C106.72 107.484 98.612 106.782 90.6563 105.613C84.056 104.642 79.375 98.8356 79.375 92.1644V67.0857C79.375 60.4144 84.056 54.6076 90.6563 53.6372C100.233 52.2292 110.031 51.5 119.999 51.5C129.968 51.5 139.766 52.2294 149.344 53.6376C155.944 54.6081 160.625 60.4148 160.625 67.086V92.1639Z"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M40.6094 45.9063C40.6094 46.8814 39.8189 47.6719 38.8438 47.6719C37.8686 47.6719 37.0781 46.8814 37.0781 45.9063C37.0781 44.9311 37.8686 44.1406 38.8438 44.1406C39.8189 44.1406 40.6094 44.9311 40.6094 45.9063ZM40.6094 45.9063H38.8438M75.9219 45.9063C75.9219 46.8814 75.1314 47.6719 74.1563 47.6719C73.1811 47.6719 72.3906 46.8814 72.3906 45.9063C72.3906 44.9311 73.1811 44.1406 74.1563 44.1406C75.1314 44.1406 75.9219 44.9311 75.9219 45.9063ZM75.9219 45.9063H74.1563M10.5938 60.0753C10.5938 67.6137 15.8832 74.1753 23.3414 75.2719C28.4551 76.0238 33.6246 76.6045 38.8438 77.0078V98.875L58.541 79.1778C59.5143 78.2044 60.8285 77.651 62.2046 77.617C71.5063 77.3866 80.6684 76.5942 89.6584 75.2725C97.1167 74.176 102.406 67.6143 102.406 60.0758V31.7368C102.406 24.1983 97.1167 17.6366 89.6584 16.5401C78.8365 14.949 67.7652 14.125 56.5013 14.125C45.2365 14.125 34.1642 14.9492 23.3414 16.5405C15.8832 17.6371 10.5938 24.1988 10.5938 31.7372V60.0753Z"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M60 50.5C60 52.433 58.433 54 56.5 54C54.567 54 53 52.433 53 50.5"
|
||||
stroke="#94A3B8"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<template #cta>
|
||||
<div v-if="showButton" class="mt-3">
|
||||
<FormButton :icon-left="PlusIcon" @click="() => $emit('new-discussion')">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<!-- eslint-disable vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<template>
|
||||
<div class="relative z-30">
|
||||
<div class="relative">
|
||||
<LayoutMenu
|
||||
v-model:open="showActionsMenu"
|
||||
:menu-id="menuId"
|
||||
:items="actionsItems"
|
||||
:menu-position="menuPosition ? menuPosition : HorizontalDirection.Left"
|
||||
@click.stop.prevent
|
||||
@chosen="onActionChosen"
|
||||
>
|
||||
@@ -46,6 +47,9 @@ import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
|
||||
import { EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import { modelVersionsRoute } from '~/lib/common/helpers/route'
|
||||
|
||||
graphql(`
|
||||
fragment ProjectPageModelsActions on Model {
|
||||
@@ -65,6 +69,7 @@ enum ActionTypes {
|
||||
Rename = 'rename',
|
||||
Delete = 'delete',
|
||||
Share = 'share',
|
||||
ViewVersions = 'view-versions',
|
||||
UploadVersion = 'upload-version',
|
||||
CopyId = 'copy-id',
|
||||
Embed = 'embed'
|
||||
@@ -82,11 +87,14 @@ const props = defineProps<{
|
||||
model: ProjectPageModelsActionsFragment
|
||||
project: ProjectPageModelsActions_ProjectFragment
|
||||
canEdit?: boolean
|
||||
menuPosition?: HorizontalDirection
|
||||
}>()
|
||||
|
||||
const copyModelLink = useCopyModelLink()
|
||||
const { copy } = useClipboard()
|
||||
const menuId = useId()
|
||||
const { isLoggedIn } = useActiveUser()
|
||||
const router = useRouter()
|
||||
|
||||
const showActionsMenu = ref(false)
|
||||
const openDialog = ref(null as Nullable<ActionTypes>)
|
||||
@@ -94,16 +102,28 @@ const embedDialogOpen = ref(false)
|
||||
|
||||
const isMain = computed(() => props.model.name === 'main')
|
||||
const actionsItems = computed<LayoutMenuItem[][]>(() => [
|
||||
...(isLoggedIn.value
|
||||
? [
|
||||
[
|
||||
{
|
||||
title: 'Edit model...',
|
||||
id: ActionTypes.Rename,
|
||||
disabled: !props.canEdit,
|
||||
disabledTooltip: 'Insufficient permissions'
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
[
|
||||
{
|
||||
title: 'Edit...',
|
||||
id: ActionTypes.Rename,
|
||||
disabled: !props.canEdit
|
||||
title: 'View versions',
|
||||
id: ActionTypes.ViewVersions
|
||||
},
|
||||
{
|
||||
title: 'Upload new version...',
|
||||
id: ActionTypes.UploadVersion,
|
||||
disabled: !props.canEdit
|
||||
disabled: !props.canEdit,
|
||||
disabledTooltip: 'Insufficient permissions'
|
||||
}
|
||||
],
|
||||
[
|
||||
@@ -111,13 +131,18 @@ const actionsItems = computed<LayoutMenuItem[][]>(() => [
|
||||
{ title: 'Copy ID', id: ActionTypes.CopyId },
|
||||
{ title: 'Embed model...', id: ActionTypes.Embed }
|
||||
],
|
||||
[
|
||||
{
|
||||
title: 'Delete...',
|
||||
id: ActionTypes.Delete,
|
||||
disabled: isMain.value || !props.canEdit
|
||||
}
|
||||
]
|
||||
...(isLoggedIn.value
|
||||
? [
|
||||
[
|
||||
{
|
||||
title: 'Delete...',
|
||||
id: ActionTypes.Delete,
|
||||
disabled: isMain.value || !props.canEdit,
|
||||
disabledTooltip: 'Insufficient permissions'
|
||||
}
|
||||
]
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
const isRenameDialogOpen = computed({
|
||||
@@ -143,6 +168,9 @@ const onActionChosen = (params: { item: LayoutMenuItem; event: MouseEvent }) =>
|
||||
mp.track('Branch Action', { type: 'action', name: 'share' })
|
||||
copyModelLink(props.project.id, props.model.id)
|
||||
break
|
||||
case ActionTypes.ViewVersions:
|
||||
router.push(modelVersionsRoute(props.project.id, props.model.id))
|
||||
break
|
||||
case ActionTypes.UploadVersion:
|
||||
emit('upload-version')
|
||||
break
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<template>
|
||||
<div v-keyboard-clickable :class="containerClasses" @click="onCardClick">
|
||||
<div class="relative p-2">
|
||||
<div
|
||||
v-keyboard-clickable
|
||||
:class="containerClasses"
|
||||
@click="onCardClick"
|
||||
@mouseleave=";(showActionsMenu = false), (hovered = false)"
|
||||
@mouseenter="hovered = true"
|
||||
>
|
||||
<div class="relative p-2 h-full flex flex-col">
|
||||
<NuxtLink
|
||||
v-if="!defaultLinkDisabled"
|
||||
:to="modelRoute(projectId, model.id)"
|
||||
class="absolute z-10 inset-0"
|
||||
/>
|
||||
<div class="relative z-30 flex justify-between items-center h-10">
|
||||
<div class="relative z-40 flex justify-between items-center h-10">
|
||||
<NuxtLink
|
||||
:to="!defaultLinkDisabled ? modelRoute(projectId, model.id) : undefined"
|
||||
class="w-full"
|
||||
class="truncate"
|
||||
>
|
||||
<div class="px-2 select-none w-full max-w-[80%]">
|
||||
<div class="px-1 select-none w-full">
|
||||
<div
|
||||
v-if="nameParts[0]"
|
||||
class="text-body-2xs text-foreground-2 relative truncate"
|
||||
@@ -37,11 +44,25 @@
|
||||
@upload-version="triggerVersionUpload"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-center my-1">
|
||||
<div class="relative flex items-center justify-center my-1 flex-1">
|
||||
<div
|
||||
v-if="
|
||||
isAutomateModuleEnabled &&
|
||||
!isPendingModelFragment(model) &&
|
||||
model.automationsStatus
|
||||
"
|
||||
class="z-30 absolute top-0 left-0"
|
||||
>
|
||||
<AutomateRunsTriggerStatus
|
||||
:project-id="projectId"
|
||||
:status="model.automationsStatus"
|
||||
:model-id="model.id"
|
||||
/>
|
||||
</div>
|
||||
<ProjectPendingFileImportStatus
|
||||
v-if="isPendingModelFragment(model)"
|
||||
:upload="model"
|
||||
class="px-4 w-full"
|
||||
class="px-4 w-full h-full"
|
||||
/>
|
||||
<ProjectPendingFileImportStatus
|
||||
v-else-if="pendingVersion"
|
||||
@@ -98,20 +119,6 @@
|
||||
</FormButton>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
isAutomateModuleEnabled &&
|
||||
!isPendingModelFragment(model) &&
|
||||
model.automationsStatus
|
||||
"
|
||||
class="z-20 absolute top-0 left-0"
|
||||
>
|
||||
<AutomateRunsTriggerStatus
|
||||
:project-id="projectId"
|
||||
:status="model.automationsStatus"
|
||||
:model-id="model.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,10 +174,11 @@ const importArea = ref(
|
||||
}>
|
||||
)
|
||||
const showActionsMenu = ref(false)
|
||||
const hovered = ref(false)
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const classParts = [
|
||||
'group rounded-xl bg-foundation border border-outline-3 hover:border-outline-5 w-full'
|
||||
'group rounded-xl bg-foundation border border-outline-3 hover:border-outline-5 w-full z-[0]'
|
||||
]
|
||||
|
||||
if (versionCount.value > 0) {
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 lg:space-y-0 lg:flex-row lg:justify-between lg:items-center mb-4"
|
||||
class="flex flex-col space-y-2 xl:space-y-0 xl:flex-row xl:justify-between xl:items-center mb-4"
|
||||
>
|
||||
<div class="flex justify-between items-center flex-wrap sm:flex-nowrap">
|
||||
<div class="flex justify-between items-center flex-wrap xl:flex-nowrap">
|
||||
<h1 class="block text-heading-xl">Models</h1>
|
||||
<div class="flex items-center space-x-2 w-full mt-2 sm:w-auto sm:mt-0">
|
||||
<FormButton
|
||||
color="outline"
|
||||
:to="allModelsRoute"
|
||||
:disabled="project?.models.totalCount === 0"
|
||||
class="grow inline-flex sm:grow-0 lg:hidden"
|
||||
@click="trackFederateAll"
|
||||
@click="onViewAllClick"
|
||||
>
|
||||
View all in 3D
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="canContribute"
|
||||
class="grow inline-flex sm:grow-0 lg:hidden"
|
||||
:icon-left="PlusIcon"
|
||||
@click="showNewDialog = true"
|
||||
>
|
||||
New model
|
||||
@@ -25,7 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col space-y-2 md:space-y-0 md:flex-row md:items-center md:space-x-2"
|
||||
class="flex flex-col space-y-2 xl:space-y-0 xl:flex-row xl:items-center xl:space-x-2"
|
||||
>
|
||||
<FormTextInput
|
||||
v-model="localSearch"
|
||||
@@ -33,11 +32,11 @@
|
||||
:show-label="false"
|
||||
placeholder="Search models..."
|
||||
color="foundation"
|
||||
wrapper-classes="grow lg:grow-0 lg:ml-2 lg:w-40 xl:w-60"
|
||||
wrapper-classes="grow lg:grow-0 xl:ml-2 xl:w-40 min-w-40 shrink-0"
|
||||
:show-clear="localSearch !== ''"
|
||||
@change="($event) => updateSearchImmediately($event.value)"
|
||||
@update:model-value="updateDebouncedSearch"
|
||||
></FormTextInput>
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col space-y-2 sm:flex-row sm:items-center sm:space-x-2 sm:space-y-0"
|
||||
>
|
||||
@@ -68,16 +67,15 @@
|
||||
</div>
|
||||
<FormButton
|
||||
color="outline"
|
||||
:to="allModelsRoute"
|
||||
class="hidden lg:inline-flex shrink-0"
|
||||
@click="trackFederateAll"
|
||||
:disabled="project?.models.totalCount === 0"
|
||||
@click="onViewAllClick"
|
||||
>
|
||||
View all in 3D
|
||||
</FormButton>
|
||||
<FormButton
|
||||
v-if="canContribute"
|
||||
class="hidden lg:inline-flex shrink-0"
|
||||
:icon-left="PlusIcon"
|
||||
@click="showNewDialog = true"
|
||||
>
|
||||
New model
|
||||
@@ -89,7 +87,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import { SourceApps, SpeckleViewer } from '@speckle/shared'
|
||||
import type { SourceAppDefinition } from '@speckle/shared'
|
||||
import { debounce } from 'lodash-es'
|
||||
@@ -116,6 +113,9 @@ graphql(`
|
||||
name
|
||||
sourceApps
|
||||
role
|
||||
models {
|
||||
totalCount
|
||||
}
|
||||
team {
|
||||
id
|
||||
user {
|
||||
@@ -138,15 +138,19 @@ const props = defineProps<{
|
||||
const localSearch = ref('')
|
||||
const sourceAppsLabelId = useId()
|
||||
const sourceAppsBtnId = useId()
|
||||
|
||||
const router = useRouter()
|
||||
const mp = useMixpanel()
|
||||
const trackFederateAll = () =>
|
||||
|
||||
const onViewAllClick = () => {
|
||||
router.push(allModelsRoute.value)
|
||||
|
||||
mp.track('Viewer Action', {
|
||||
type: 'action',
|
||||
name: 'federation',
|
||||
action: 'view-all',
|
||||
source: 'project page'
|
||||
})
|
||||
}
|
||||
|
||||
const canContribute = computed(() =>
|
||||
props.project ? canModifyModels(props.project) : false
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
hide-closer
|
||||
:buttons="dialogButtons"
|
||||
>
|
||||
<template #header>Create New Model</template>
|
||||
<template #header>Create new model</template>
|
||||
<form @submit="onSubmit">
|
||||
<div class="flex flex-col space-y-6 mb-4">
|
||||
<FormTextInput
|
||||
v-model="newModelName"
|
||||
color="foundation"
|
||||
name="name"
|
||||
label="Model Name"
|
||||
label="Model name"
|
||||
show-label
|
||||
placeholder="model/name/here"
|
||||
:custom-icon="CubeIcon"
|
||||
@@ -26,8 +26,9 @@
|
||||
color="foundation"
|
||||
name="description"
|
||||
show-label
|
||||
label="Model Description"
|
||||
placeholder="Description (Optional)"
|
||||
show-optional
|
||||
label="Model description"
|
||||
placeholder="Description"
|
||||
size="lg"
|
||||
:disabled="anyMutationsLoading"
|
||||
/>
|
||||
@@ -99,14 +100,14 @@ watch(
|
||||
const dialogButtons = computed((): LayoutDialogButton[] => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
openState.value = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Create',
|
||||
props: { fullWidth: true },
|
||||
props: {},
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
},
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
||||
<!-- eslint-disable vuejs-accessibility/mouse-events-have-key-events -->
|
||||
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
|
||||
<template>
|
||||
<div
|
||||
v-keyboard-clickable
|
||||
class="space-y-4 relative"
|
||||
:class="model && !isEmptyModel ? 'cursor-pointer' : undefined"
|
||||
@click="onCardClick"
|
||||
@mouseleave="showActionsMenu = false"
|
||||
>
|
||||
<div class="space-y-4 relative" @mouseleave="showActionsMenu = false">
|
||||
<div
|
||||
v-if="itemType !== StructureItemType.ModelWithOnlySubmodels"
|
||||
class="group relative bg-foundation w-full p-2 flex flex-row rounded-md transition-all border border-outline-3 hover:border-outline-5 items-stretch"
|
||||
class="group relative bg-foundation w-full p-2 flex flex-row rounded-md transition-all border border-outline-3 items-stretch"
|
||||
>
|
||||
<div class="flex items-center flex-grow order-2 sm:order-1 pl-2 sm:pl-4">
|
||||
<!-- Name -->
|
||||
<div
|
||||
class="flex justify-between sm:justify-start gap-2 items-center w-full sm:w-auto"
|
||||
>
|
||||
<span class="text-heading text-foreground">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="model"
|
||||
class="opacity-100 sm:opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<NuxtLink :to="modelLink || undefined">
|
||||
<span class="text-heading text-foreground hover:text-primary">
|
||||
{{ name }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<span v-if="model">
|
||||
<ProjectPageModelsActions
|
||||
v-model:open="showActionsMenu"
|
||||
:model="model"
|
||||
:project="project"
|
||||
:can-edit="canContribute"
|
||||
:menu-position="
|
||||
itemType === StructureItemType.EmptyModel
|
||||
? HorizontalDirection.Right
|
||||
: HorizontalDirection.Left
|
||||
"
|
||||
@click.stop.prevent
|
||||
@model-updated="$emit('model-updated')"
|
||||
@upload-version="triggerVersionUpload"
|
||||
@@ -126,7 +121,7 @@
|
||||
>
|
||||
<NuxtLink
|
||||
:to="modelLink || ''"
|
||||
class="h-full w-full block bg-foundation-page rounded-lg border border-outline-3"
|
||||
class="h-full w-full block bg-foundation-page rounded-lg border border-outline-3 hover:border-outline-5"
|
||||
>
|
||||
<PreviewImage
|
||||
v-if="item.model?.previewUrl"
|
||||
@@ -235,6 +230,7 @@ import { has } from 'lodash-es'
|
||||
import type { Nullable } from '@speckle/shared'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { useIsModelExpanded } from '~~/lib/projects/composables/models'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
|
||||
/**
|
||||
* TODO: The template in this file is a complete mess, needs refactoring
|
||||
@@ -385,7 +381,9 @@ const modelLink = computed(() => {
|
||||
|
||||
const viewAllUrl = computed(() => {
|
||||
if (isPendingFileUpload(props.item)) return undefined
|
||||
return modelRoute(props.project.id, `$${props.item.fullName}`)
|
||||
const fullName = props.item.fullName
|
||||
const encodedFullName = `$${fullName}`.replace(/\//g, '%2F')
|
||||
return modelRoute(props.project.id, encodedFullName)
|
||||
})
|
||||
|
||||
const {
|
||||
@@ -405,10 +403,6 @@ const {
|
||||
|
||||
const children = computed(() => childrenResult.value?.project?.modelChildrenTree || [])
|
||||
|
||||
const isEmptyModel = computed(() => {
|
||||
return itemType.value === StructureItemType.EmptyModel
|
||||
})
|
||||
|
||||
const onModelUpdated = () => {
|
||||
emit('model-updated')
|
||||
refetchChildren()
|
||||
@@ -418,12 +412,6 @@ const triggerVersionUpload = () => {
|
||||
importArea.value?.triggerPicker()
|
||||
}
|
||||
|
||||
const onCardClick = () => {
|
||||
if (model.value && !isEmptyModel.value) {
|
||||
router.push(modelRoute(props.project.id, model.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const onVersionsClick = () => {
|
||||
if (model.value) {
|
||||
router.push(modelVersionsRoute(props.project.id, model.value.id))
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
:buttons="[
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
props: { color: 'danger', fullWidth: true, disabled: loading },
|
||||
props: { color: 'danger', disabled: loading },
|
||||
onClick: () => {
|
||||
onDelete()
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
:buttons="[
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen = false
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
props: { fullWidth: true },
|
||||
props: {},
|
||||
onClick: () => {
|
||||
onSubmit()
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
>
|
||||
<template #header>Edit model</template>
|
||||
<form class="flex flex-col text-foreground" @submit="onSubmit">
|
||||
<div class="flex flex-col gap-6 my-2">
|
||||
<div class="flex flex-col gap-4 mb-4">
|
||||
<FormTextInput
|
||||
v-model="newName"
|
||||
name="name"
|
||||
@@ -29,7 +29,6 @@
|
||||
label="Model name"
|
||||
placeholder="model/name/here"
|
||||
:rules="rules"
|
||||
show-required
|
||||
auto-focus
|
||||
color="foundation"
|
||||
:disabled="loading"
|
||||
@@ -41,7 +40,8 @@
|
||||
name="description"
|
||||
show-label
|
||||
label="Model description"
|
||||
placeholder="Description (optional)"
|
||||
show-optional
|
||||
placeholder="Description"
|
||||
color="foundation"
|
||||
:disabled="loading"
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
v-if="$slots.introduction"
|
||||
class="text-foreground text-body-sm"
|
||||
:class="background ? 'px-4 sm:px-6 pt-4' : 'pt-6'"
|
||||
:class="background ? 'px-4 sm:px-6 pt-4' : 'pt-4'"
|
||||
>
|
||||
<slot name="introduction" />
|
||||
</div>
|
||||
|
||||
@@ -6,56 +6,19 @@
|
||||
</p>
|
||||
</template>
|
||||
<template #top-buttons>
|
||||
<FormButton :icon-left="UserPlusIcon" @click="toggleInviteDialog">
|
||||
Invite
|
||||
</FormButton>
|
||||
<FormButton @click="toggleInviteDialog">Invite</FormButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col mt-6">
|
||||
<div
|
||||
<ProjectPageSettingsCollaboratorsRow
|
||||
v-for="collaborator in collaboratorListItems"
|
||||
:key="collaborator.id"
|
||||
class="bg-foundation flex items-center gap-2 py-2 px-3 border-t border-x last:border-b border-outline-3 first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
<UserAvatar :user="collaborator.user" />
|
||||
<span class="grow truncate text-body-xs">{{ collaborator.title }}</span>
|
||||
|
||||
<template v-if="!collaborator.inviteId">
|
||||
<ProjectPageTeamPermissionSelect
|
||||
v-if="canEdit && activeUser && collaborator.id !== activeUser.id"
|
||||
class="shrink-0"
|
||||
:model-value="collaborator.role"
|
||||
:disabled="loading"
|
||||
:hide-owner="collaborator.serverRole === Roles.Server.Guest"
|
||||
@update:model-value="onCollaboratorRoleChange(collaborator, $event)"
|
||||
@delete="onCollaboratorRoleChange(collaborator, null)"
|
||||
/>
|
||||
<span v-else class="shrink-0 text-body-2xs">
|
||||
{{ roleSelectItems[collaborator.role].title }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="canEdit">
|
||||
<div class="flex items-end sm:items-center shrink-0 gap-3">
|
||||
<span class="shrink-0 text-foreground-2 text-sm">
|
||||
{{ roleSelectItems[collaborator.role].title }}
|
||||
</span>
|
||||
<FormButton
|
||||
class="shrink-0"
|
||||
color="danger"
|
||||
size="sm"
|
||||
:disabled="loading"
|
||||
@click="
|
||||
cancelInvite({
|
||||
projectId,
|
||||
inviteId: collaborator.inviteId || ''
|
||||
})
|
||||
"
|
||||
>
|
||||
Cancel invite
|
||||
</FormButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
:can-edit="canEdit"
|
||||
:collaborator="collaborator"
|
||||
:loading="loading"
|
||||
@cancel-invite="onCancelInvite"
|
||||
@change-role="onCollaboratorRoleChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectPageInviteDialog
|
||||
@@ -68,11 +31,13 @@
|
||||
</ProjectPageSettingsBlock>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Roles } from '@speckle/shared'
|
||||
import type { Nullable, StreamRoles } from '@speckle/shared'
|
||||
import { useApolloClient, useQuery } from '@vue/apollo-composable'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
import type { Project } from '~~/lib/common/generated/gql/graphql'
|
||||
import type { ProjectCollaboratorListItem } from '~~/lib/projects/helpers/components'
|
||||
import type { Nullable, StreamRoles } from '@speckle/shared'
|
||||
import { useQuery, useApolloClient } from '@vue/apollo-composable'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import {
|
||||
getCacheId,
|
||||
getObjectReference,
|
||||
@@ -82,12 +47,6 @@ import {
|
||||
useCancelProjectInvite,
|
||||
useUpdateUserRole
|
||||
} from '~~/lib/projects/composables/projectManagement'
|
||||
import { useTeamInternals } from '~~/lib/projects/composables/team'
|
||||
import { roleSelectItems } from '~~/lib/projects/helpers/components'
|
||||
import type { ProjectCollaboratorListItem } from '~~/lib/projects/helpers/components'
|
||||
import { UserPlusIcon } from '@heroicons/vue/24/outline'
|
||||
import { useMixpanel } from '~~/lib/core/composables/mp'
|
||||
import { graphql } from '~~/lib/common/generated/gql'
|
||||
|
||||
const projectPageSettingsCollaboratorsQuery = graphql(`
|
||||
query ProjectPageSettingsCollaborators($projectId: String!) {
|
||||
@@ -99,12 +58,19 @@ const projectPageSettingsCollaboratorsQuery = graphql(`
|
||||
}
|
||||
`)
|
||||
|
||||
const projectPageSettingsCollaboratorWorkspaceQuery = graphql(`
|
||||
query ProjectPageSettingsCollaboratorsWorkspace($workspaceId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
...ProjectPageTeamInternals_Workspace
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const route = useRoute()
|
||||
const isWorkspacesEnabled = useIsWorkspacesEnabled()
|
||||
const apollo = useApolloClient().client
|
||||
const updateRole = useUpdateUserRole()
|
||||
const cancelInvite = useCancelProjectInvite()
|
||||
const { activeUser } = useActiveUser()
|
||||
const mp = useMixpanel()
|
||||
const cancelInvite = useCancelProjectInvite()
|
||||
|
||||
const showInviteDialog = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -114,10 +80,28 @@ const projectId = computed(() => route.params.id as string)
|
||||
const { result: pageResult } = useQuery(projectPageSettingsCollaboratorsQuery, () => ({
|
||||
projectId: projectId.value
|
||||
}))
|
||||
const { result: workspaceResult } = useQuery(
|
||||
projectPageSettingsCollaboratorWorkspaceQuery,
|
||||
() => ({
|
||||
workspaceId: pageResult.value!.project.workspaceId!
|
||||
}),
|
||||
() => ({
|
||||
enabled: isWorkspacesEnabled.value && !!pageResult.value?.project.workspaceId
|
||||
})
|
||||
)
|
||||
|
||||
const project = computed(() => pageResult.value?.project)
|
||||
const workspace = computed(() => workspaceResult.value?.workspace)
|
||||
const updateRole = useUpdateUserRole(project)
|
||||
|
||||
const { collaboratorListItems, isOwner, isServerGuest } = useTeamInternals(project)
|
||||
const toggleInviteDialog = () => {
|
||||
showInviteDialog.value = true
|
||||
}
|
||||
|
||||
const { collaboratorListItems, isOwner, isServerGuest } = useTeamInternals(
|
||||
project,
|
||||
workspace
|
||||
)
|
||||
|
||||
const canEdit = computed(() => isOwner.value && !isServerGuest.value)
|
||||
|
||||
@@ -138,7 +122,9 @@ const onCollaboratorRoleChange = async (
|
||||
mp.track('Stream Action', {
|
||||
type: 'action',
|
||||
name: 'update',
|
||||
action: 'team member role'
|
||||
action: 'team member role',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: workspace.value?.id
|
||||
})
|
||||
|
||||
if (!newRole) {
|
||||
@@ -161,7 +147,10 @@ const onCollaboratorRoleChange = async (
|
||||
}
|
||||
}
|
||||
|
||||
const toggleInviteDialog = () => {
|
||||
showInviteDialog.value = true
|
||||
const onCancelInvite = (inviteId: string) => {
|
||||
cancelInvite({
|
||||
projectId: projectId.value,
|
||||
inviteId
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-foundation flex items-center gap-2 py-2 px-3 border-t border-x last:border-b border-outline-3 first:rounded-t-lg last:rounded-b-lg"
|
||||
>
|
||||
<UserAvatar hide-tooltip :user="collaborator.user" />
|
||||
<div class="flex flex-col grow">
|
||||
<span class="truncate text-body-xs">{{ collaborator.title }}</span>
|
||||
<span
|
||||
v-if="collaborator.inviteId"
|
||||
class="truncate text-body-2xs text-foreground-2"
|
||||
>
|
||||
Pending invite
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="!collaborator.inviteId">
|
||||
<ProjectPageTeamPermissionSelect
|
||||
v-if="
|
||||
canEdit &&
|
||||
activeUser &&
|
||||
collaborator.id !== activeUser.id &&
|
||||
collaborator.workspaceRole !== Roles.Workspace.Admin
|
||||
"
|
||||
class="shrink-0"
|
||||
:model-value="collaborator.role"
|
||||
:disabled="loading"
|
||||
:hide-owner="collaborator.serverRole === Roles.Server.Guest"
|
||||
:disabled-roles="isTargettingWorkspaceGuest ? [Roles.Stream.Owner] : []"
|
||||
:disabled-item-tooltip="
|
||||
isTargettingWorkspaceGuest ? 'Workspace guests cannot be project owners' : ''
|
||||
"
|
||||
@update:model-value="emit('changeRole', collaborator, $event)"
|
||||
/>
|
||||
<div v-else class="flex items-center justify-end">
|
||||
<span v-tippy="roleTooltip" class="shrink-0 text-body-2xs">
|
||||
{{ roleSelectItems[collaborator.role].title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="canEdit">
|
||||
<div class="flex items-end sm:items-center shrink-0 gap-3">
|
||||
<span class="shrink-0 text-body-2xs">
|
||||
{{ roleSelectItems[collaborator.role].title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<LayoutMenu
|
||||
v-if="
|
||||
canEdit &&
|
||||
activeUser &&
|
||||
collaborator.id !== activeUser.id &&
|
||||
collaborator.workspaceRole !== Roles.Workspace.Admin
|
||||
"
|
||||
v-model:open="showActionsMenu"
|
||||
:items="actionsItems"
|
||||
:menu-position="HorizontalDirection.Left"
|
||||
:menu-id="menuId"
|
||||
@click.stop.prevent
|
||||
@chosen="onActionChosen($event, collaborator)"
|
||||
>
|
||||
<FormButton
|
||||
color="subtle"
|
||||
hide-text
|
||||
:icon-right="EllipsisHorizontalIcon"
|
||||
@click="showActionsMenu = !showActionsMenu"
|
||||
/>
|
||||
</LayoutMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ProjectCollaboratorListItem } from '~~/lib/projects/helpers/components'
|
||||
import type { LayoutMenuItem } from '~~/lib/layout/helpers/components'
|
||||
import type { Nullable, StreamRoles } from '@speckle/shared'
|
||||
import { Roles } from '@speckle/shared'
|
||||
import { HorizontalDirection } from '~~/lib/common/composables/window'
|
||||
import { EllipsisHorizontalIcon } from '@heroicons/vue/24/solid'
|
||||
import { roleSelectItems } from '~~/lib/projects/helpers/components'
|
||||
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
||||
|
||||
enum ActionTypes {
|
||||
Remove = 'remove'
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelInvite', inviteId: string): void
|
||||
(
|
||||
e: 'changeRole',
|
||||
collaborator: ProjectCollaboratorListItem,
|
||||
newRole: Nullable<StreamRoles>
|
||||
): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
canEdit: boolean
|
||||
collaborator: ProjectCollaboratorListItem
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const { activeUser } = useActiveUser()
|
||||
|
||||
const showActionsMenu = ref(false)
|
||||
const menuId = useId()
|
||||
|
||||
const actionsItems = computed<LayoutMenuItem[][]>(() => [
|
||||
[
|
||||
{
|
||||
title: props.collaborator.inviteId ? 'Cancel invite' : 'Remove user',
|
||||
id: ActionTypes.Remove,
|
||||
disabled: props.loading
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
const roleTooltip = computed(() => {
|
||||
if (!props.canEdit) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.collaborator.workspaceRole === Roles.Workspace.Admin) {
|
||||
return 'User is workspace admin'
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isTargettingWorkspaceGuest = computed(
|
||||
() => props.collaborator.workspaceRole === Roles.Workspace.Guest
|
||||
)
|
||||
|
||||
const onActionChosen = (
|
||||
params: { item: LayoutMenuItem; event: MouseEvent },
|
||||
collaborator: ProjectCollaboratorListItem
|
||||
) => {
|
||||
const { item } = params
|
||||
|
||||
switch (item.id) {
|
||||
case ActionTypes.Remove:
|
||||
if (collaborator.inviteId) {
|
||||
emit('cancelInvite', collaborator.inviteId)
|
||||
} else {
|
||||
emit('changeRole', collaborator, null)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -71,22 +71,9 @@ const updateProject = useUpdateProject()
|
||||
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
|
||||
const { result: pageResult } = useQuery(
|
||||
projectPageSettingsGeneralQuery,
|
||||
() => ({
|
||||
projectId: projectId.value
|
||||
}),
|
||||
() => ({
|
||||
// Custom error policy so that a failing invitedTeam resolver (due to access rights)
|
||||
// doesn't kill the entire query
|
||||
errorPolicy: 'all',
|
||||
context: {
|
||||
skipLoggingErrors: (err) =>
|
||||
err.graphQLErrors?.length === 1 &&
|
||||
err.graphQLErrors.some((e) => !!e.path?.includes('invitedTeam'))
|
||||
}
|
||||
})
|
||||
)
|
||||
const { result: pageResult } = useQuery(projectPageSettingsGeneralQuery, () => ({
|
||||
projectId: projectId.value
|
||||
}))
|
||||
|
||||
const project = computed(() => pageResult.value?.project)
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ graphql(`
|
||||
commentThreads(limit: 0) {
|
||||
totalCount
|
||||
}
|
||||
workspace {
|
||||
slug
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<LayoutDialog v-model:open="isOpen" max-width="md" :buttons="dialogButtons">
|
||||
<template #header>Delete project</template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div class="space-y-4 text-body-xs">
|
||||
<p>
|
||||
Are you sure you want to permanently
|
||||
<strong>delete “{{ project.name }}”</strong>
|
||||
@@ -61,7 +61,7 @@ const discussionText = computed(() =>
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
projectNameInput.value = ''
|
||||
@@ -71,7 +71,7 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
text: 'Delete',
|
||||
props: {
|
||||
color: 'danger',
|
||||
fullWidth: true,
|
||||
|
||||
disabled: projectNameInput.value !== props.project.name
|
||||
},
|
||||
onClick: async () => {
|
||||
@@ -79,7 +79,10 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
projectNameInput.value === props.project.name &&
|
||||
props.project.role === Roles.Stream.Owner
|
||||
) {
|
||||
await deleteProject(props.project.id, { goHome: true })
|
||||
await deleteProject(props.project.id, {
|
||||
goHome: true,
|
||||
workspaceSlug: props.project.workspace?.slug
|
||||
})
|
||||
isOpen.value = false
|
||||
mp.track('Stream Action', { type: 'action', name: 'delete' })
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const mp = useMixpanel()
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Cancel',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
@@ -38,7 +38,7 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
text: 'Leave',
|
||||
props: {
|
||||
color: 'danger',
|
||||
fullWidth: true,
|
||||
|
||||
submit: true
|
||||
},
|
||||
onClick: onLeave
|
||||
@@ -47,6 +47,11 @@ const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
|
||||
const onLeave = async () => {
|
||||
await leaveProject(props.project.id, { goHome: true })
|
||||
mp.track('Stream Action', { type: 'action', name: 'leave' })
|
||||
mp.track('Stream Action', {
|
||||
type: 'action',
|
||||
name: 'leave',
|
||||
// eslint-disable-next-line camelcase
|
||||
workspace_id: props.project.workspace?.id
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<ProjectPageSettingsBlock v-if="canLeaveProject" background title="Leave Project">
|
||||
<ProjectPageSettingsBlock v-if="canLeaveProject" background title="Leave project">
|
||||
<p>
|
||||
Remove yourself from this project. To join again you will need to get invited.
|
||||
</p>
|
||||
@@ -35,6 +35,9 @@ graphql(`
|
||||
role
|
||||
}
|
||||
}
|
||||
workspace {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
v-model="localProjectDescription"
|
||||
name="projectDescription"
|
||||
label="Project description"
|
||||
placeholder="Description (optional)"
|
||||
placeholder="Description"
|
||||
show-label
|
||||
show-optional
|
||||
color="foundation"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
@@ -113,13 +114,12 @@ const resetLocalState = () => {
|
||||
const dialogButtons = computed<LayoutDialogButton[]>(() => [
|
||||
{
|
||||
text: 'Discard Changes',
|
||||
props: { color: 'outline', fullWidth: true },
|
||||
props: { color: 'outline' },
|
||||
onClick: handleRedirection
|
||||
},
|
||||
{
|
||||
text: 'Save Changes',
|
||||
props: {
|
||||
fullWidth: true,
|
||||
submit: true
|
||||
},
|
||||
onClick: () => {
|
||||
|
||||