Merge branch 'main' into iain/dockerfile-node18-node22

This commit is contained in:
Iain Sproat
2024-10-14 19:36:48 +01:00
916 changed files with 58586 additions and 22339 deletions
+64 -55
View File
@@ -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" }}
+23 -3
View File
@@ -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>'
})
-894
View File
File diff suppressed because one or more lines are too long
+925
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
+35
View File
@@ -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
+3 -2
View File
@@ -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 -2
View File
@@ -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",
+330 -10
View File
@@ -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; }>;
+3 -1
View File
@@ -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
})
+1 -1
View File
@@ -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'
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

+1 -1
View File
@@ -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: () => {

Some files were not shown because too many files have changed in this diff Show More