Feat: Move settings from individual pages into one settings modal (#2502)

This commit is contained in:
Mike
2024-07-23 11:37:50 +02:00
committed by GitHub
parent 66eb539aa0
commit 65c7dbd247
47 changed files with 1976 additions and 1758 deletions
+309 -175
View File
@@ -18,7 +18,6 @@ export type Scalars = {
BigInt: { input: any; output: any; }
/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */
DateTime: { input: string; output: string; }
EmailAddress: { input: any; output: any; }
/** The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
JSONObject: { input: {}; output: {}; }
};
@@ -201,6 +200,8 @@ export type AutomateAuthCodePayloadTest = {
export type AutomateFunction = {
__typename?: 'AutomateFunction';
automationCount: Scalars['Int']['output'];
/** Only returned if user is a part of this speckle server */
creator?: Maybe<LimitedUser>;
description: Scalars['String']['output'];
id: Scalars['ID']['output'];
isFeatured: Scalars['Boolean']['output'];
@@ -373,62 +374,6 @@ export type AutomationCollection = {
totalCount: Scalars['Int']['output'];
};
export type AutomationCreateInput = {
automationId: Scalars['String']['input'];
automationName: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
modelId: Scalars['String']['input'];
projectId: Scalars['String']['input'];
webhookId?: InputMaybe<Scalars['String']['input']>;
};
export type AutomationFunctionRun = {
__typename?: 'AutomationFunctionRun';
contextView?: Maybe<Scalars['String']['output']>;
elapsed: Scalars['Float']['output'];
functionId: Scalars['String']['output'];
functionLogo?: Maybe<Scalars['String']['output']>;
functionName: Scalars['String']['output'];
id: Scalars['ID']['output'];
resultVersions: Array<Version>;
/**
* NOTE: this is the schema for the results field below!
* Current schema: {
* version: "1.0.0",
* values: {
* objectResults: Record<str, {
* category: string
* level: ObjectResultLevel
* objectIds: string[]
* message: str | null
* metadata: Records<str, unknown> | null
* visualoverrides: Records<str, unknown> | null
* }[]>
* blobIds?: string[]
* }
* }
*/
results?: Maybe<Scalars['JSONObject']['output']>;
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AutomationMutations = {
__typename?: 'AutomationMutations';
create: Scalars['Boolean']['output'];
functionRunStatusReport: Scalars['Boolean']['output'];
};
export type AutomationMutationsCreateArgs = {
input: AutomationCreateInput;
};
export type AutomationMutationsFunctionRunStatusReportArgs = {
input: AutomationRunStatusUpdateInput;
};
export type AutomationRevision = {
__typename?: 'AutomationRevision';
functions: Array<AutomationRevisionFunction>;
@@ -452,44 +397,8 @@ export type AutomationRevisionFunction = {
export type AutomationRevisionTriggerDefinition = VersionCreatedTriggerDefinition;
export type AutomationRun = {
__typename?: 'AutomationRun';
automationId: Scalars['String']['output'];
automationName: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
functionRuns: Array<AutomationFunctionRun>;
id: Scalars['ID']['output'];
/** Resolved from all function run statuses */
status: AutomationRunStatus;
updatedAt: Scalars['DateTime']['output'];
versionId: Scalars['String']['output'];
};
export enum AutomationRunStatus {
Failed = 'FAILED',
Initializing = 'INITIALIZING',
Running = 'RUNNING',
Succeeded = 'SUCCEEDED'
}
export type AutomationRunStatusUpdateInput = {
automationId: Scalars['String']['input'];
automationRevisionId: Scalars['String']['input'];
automationRunId: Scalars['String']['input'];
functionRuns: Array<FunctionRunStatusInput>;
versionId: Scalars['String']['input'];
};
export type AutomationRunTrigger = VersionCreatedTrigger;
export type AutomationsStatus = {
__typename?: 'AutomationsStatus';
automationRuns: Array<AutomationRun>;
id: Scalars['ID']['output'];
status: AutomationRunStatus;
statusMessage?: Maybe<Scalars['String']['output']>;
};
export type AvatarUser = {
__typename?: 'AvatarUser';
avatar?: Maybe<Scalars['String']['output']>;
@@ -529,7 +438,10 @@ export type BlobMetadataCollection = {
export type Branch = {
__typename?: 'Branch';
/** All the recent activity on this branch in chronological order */
/**
* All the recent activity on this branch in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
activity?: Maybe<ActivityCollection>;
author?: Maybe<User>;
commits?: Maybe<CommitCollection>;
@@ -744,7 +656,10 @@ export type CommentThreadActivityMessage = {
export type Commit = {
__typename?: 'Commit';
/** All the recent activity on this commit in chronological order */
/**
* All the recent activity on this commit in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
activity?: Maybe<ActivityCollection>;
authorAvatar?: Maybe<Scalars['String']['output']>;
authorId?: Maybe<Scalars['String']['output']>;
@@ -760,6 +675,7 @@ export type Commit = {
* ...
* }
* ```
* @deprecated Part of the old API surface and will be removed in the future.
*/
commentCount: Scalars['Int']['output'];
createdAt?: Maybe<Scalars['DateTime']['output']>;
@@ -881,6 +797,16 @@ export type CreateModelInput = {
projectId: Scalars['ID']['input'];
};
export type CreateVersionInput = {
message?: InputMaybe<Scalars['String']['input']>;
modelId: Scalars['String']['input'];
objectId: Scalars['String']['input'];
parents?: InputMaybe<Array<Scalars['String']['input']>>;
projectId: Scalars['String']['input'];
sourceApplication?: InputMaybe<Scalars['String']['input']>;
totalChildrenCount?: InputMaybe<Scalars['Int']['input']>;
};
export type DeleteModelInput = {
id: Scalars['ID']['input'];
projectId: Scalars['ID']['input'];
@@ -934,27 +860,6 @@ export type FileUpload = {
userId: Scalars['String']['output'];
};
export type FunctionRunStatusInput = {
contextView?: InputMaybe<Scalars['String']['input']>;
elapsed: Scalars['Float']['input'];
functionId: Scalars['String']['input'];
functionLogo?: InputMaybe<Scalars['String']['input']>;
functionName: Scalars['String']['input'];
resultVersionIds: Array<Scalars['String']['input']>;
/**
* Current schema: {
* version: "1.0.0",
* values: {
* speckleObjects: Record<ObjectId, {level: string; statusMessage: string}[]>
* blobIds?: string[]
* }
* }
*/
results?: InputMaybe<Scalars['JSONObject']['input']>;
status: AutomationRunStatus;
statusMessage?: InputMaybe<Scalars['String']['input']>;
};
export type GendoAiRender = {
__typename?: 'GendoAIRender';
camera?: Maybe<Scalars['JSONObject']['output']>;
@@ -1013,21 +918,36 @@ export type LegacyCommentViewerData = {
*/
export type LimitedUser = {
__typename?: 'LimitedUser';
/** All the recent activity from this user in chronological order */
/**
* All the recent activity from this user in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
activity?: Maybe<ActivityCollection>;
avatar?: Maybe<Scalars['String']['output']>;
bio?: Maybe<Scalars['String']['output']>;
/** Get public stream commits authored by the user */
/**
* Get public stream commits authored by the user
* @deprecated Part of the old API surface and will be removed in the future.
*/
commits?: Maybe<CommitCollection>;
company?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
role?: Maybe<Scalars['String']['output']>;
/** Returns all discoverable streams that the user is a collaborator on */
/**
* Returns all discoverable streams that the user is a collaborator on
* @deprecated Part of the old API surface and will be removed in the future.
*/
streams: StreamCollection;
/** The user's timeline in chronological order */
/**
* The user's timeline in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
timeline?: Maybe<ActivityCollection>;
/** Total amount of favorites attached to streams owned by the user */
/**
* Total amount of favorites attached to streams owned by the user
* @deprecated Part of the old API surface and will be removed in the future.
*/
totalOwnedStreamsFavorites: Scalars['Int']['output'];
verified?: Maybe<Scalars['Boolean']['output']>;
};
@@ -1077,10 +997,16 @@ export type LimitedUserTimelineArgs = {
limit?: Scalars['Int']['input'];
};
export type MarkReceivedVersionInput = {
message?: InputMaybe<Scalars['String']['input']>;
projectId: Scalars['String']['input'];
sourceApplication: Scalars['String']['input'];
versionId: Scalars['String']['input'];
};
export type Model = {
__typename?: 'Model';
author: LimitedUser;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** Return a model tree of children */
childrenTree: Array<ModelsTreeItem>;
@@ -1213,9 +1139,11 @@ export type Mutation = {
appUpdate: Scalars['Boolean']['output'];
automateFunctionRunStatusReport: Scalars['Boolean']['output'];
automateMutations: AutomateMutations;
automationMutations: AutomationMutations;
/** @deprecated Part of the old API surface and will be removed in the future. Use ModelMutations.create instead. */
branchCreate: Scalars['String']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use ModelMutations.delete instead. */
branchDelete: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use ModelMutations.update instead. */
branchUpdate: Scalars['Boolean']['output'];
/** Broadcast user activity in the viewer */
broadcastViewerUserActivity: Scalars['Boolean']['output'];
@@ -1245,13 +1173,23 @@ export type Mutation = {
* @deprecated Use commentMutations version
*/
commentView: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.create instead. */
commitCreate: Scalars['String']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.delete instead. */
commitDelete: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.markReceived instead. */
commitReceive: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.update/moveToModel instead. */
commitUpdate: Scalars['Boolean']['output'];
/** Delete a batch of commits */
/**
* Delete a batch of commits
* @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.delete instead.
*/
commitsDelete: Scalars['Boolean']['output'];
/** Move a batch of commits to a new branch */
/**
* Move a batch of commits to a new branch
* @deprecated Part of the old API surface and will be removed in the future. Use VersionMutations.moveToModel instead.
*/
commitsMove: Scalars['Boolean']['output'];
/**
* Delete a pending invite
@@ -1264,7 +1202,8 @@ export type Mutation = {
*/
inviteResend: Scalars['Boolean']['output'];
modelMutations: ModelMutations;
objectCreate: Array<Maybe<Scalars['String']['output']>>;
/** @deprecated Part of the old API surface and will be removed in the future. */
objectCreate: Array<Scalars['String']['output']>;
projectMutations: ProjectMutations;
/** (Re-)send the account verification e-mail */
requestVerification: Scalars['Boolean']['output'];
@@ -1274,37 +1213,71 @@ export type Mutation = {
serverInviteBatchCreate: Scalars['Boolean']['output'];
/** Invite a new user to the speckle server and return the invite ID */
serverInviteCreate: Scalars['Boolean']['output'];
/** Request access to a specific stream */
/**
* Request access to a specific stream
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.create instead.
*/
streamAccessRequestCreate: StreamAccessRequest;
/** Accept or decline a stream access request. Must be a stream owner to invoke this. */
/**
* Accept or decline a stream access request. Must be a stream owner to invoke this.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectAccessRequestMutations.use instead.
*/
streamAccessRequestUse: Scalars['Boolean']['output'];
/** Creates a new stream. */
/**
* Creates a new stream.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.create instead.
*/
streamCreate?: Maybe<Scalars['String']['output']>;
/** Deletes an existing stream. */
/**
* Deletes an existing stream.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.delete instead.
*/
streamDelete: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. */
streamFavorite?: Maybe<Stream>;
/** Note: The required scope to invoke this is not given out to app or personal access tokens */
/**
* Note: The required scope to invoke this is not given out to app or personal access tokens
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.batchCreate instead.
*/
streamInviteBatchCreate: Scalars['Boolean']['output'];
/**
* Cancel a pending stream invite. Can only be invoked by a stream owner.
* Note: The required scope to invoke this is not given out to app or personal access tokens
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.cancel instead.
*/
streamInviteCancel: Scalars['Boolean']['output'];
/**
* Invite a new or registered user to the specified stream
* Note: The required scope to invoke this is not given out to app or personal access tokens
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.create instead.
*/
streamInviteCreate: Scalars['Boolean']['output'];
/** Accept or decline a stream invite */
/**
* Accept or decline a stream invite
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectInviteMutations.use instead.
*/
streamInviteUse: Scalars['Boolean']['output'];
/** Remove yourself from stream collaborators (not possible for the owner) */
/**
* Remove yourself from stream collaborators (not possible for the owner)
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.leave instead.
*/
streamLeave: Scalars['Boolean']['output'];
/** Revokes the permissions of a user on a given stream. */
/**
* Revokes the permissions of a user on a given stream.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.updateRole instead.
*/
streamRevokePermission?: Maybe<Scalars['Boolean']['output']>;
/** Updates an existing stream. */
/**
* Updates an existing stream.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.update instead.
*/
streamUpdate: Scalars['Boolean']['output'];
/** Update permissions of a user on a given stream. */
/**
* Update permissions of a user on a given stream.
* @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.updateRole instead.
*/
streamUpdatePermission?: Maybe<Scalars['Boolean']['output']>;
/** @deprecated Part of the old API surface and will be removed in the future. Use ProjectMutations.batchDelete instead. */
streamsDelete: Scalars['Boolean']['output'];
/**
* Used for broadcasting real time typing status in comment threads. Does not persist any info.
@@ -1621,6 +1594,7 @@ export type MutationWebhookUpdateArgs = {
export type Object = {
__typename?: 'Object';
/** @deprecated Not implemented. */
applicationId?: Maybe<Scalars['String']['output']>;
/**
* Get any objects that this object references. In the case of commits, this will give you a commit's constituent objects.
@@ -1636,6 +1610,7 @@ export type Object = {
* ...
* }
* ```
* @deprecated Part of the old API surface and will be removed in the future.
*/
commentCount: Scalars['Int']['output'];
createdAt?: Maybe<Scalars['DateTime']['output']>;
@@ -1659,7 +1634,7 @@ export type ObjectChildrenArgs = {
export type ObjectCollection = {
__typename?: 'ObjectCollection';
cursor?: Maybe<Scalars['String']['output']>;
objects: Array<Maybe<Object>>;
objects: Array<Object>;
totalCount: Scalars['Int']['output'];
};
@@ -1699,7 +1674,9 @@ export type PendingStreamCollaborator = {
projectId: Scalars['String']['output'];
projectName: Scalars['String']['output'];
role: Scalars['String']['output'];
/** @deprecated Use projectId instead */
streamId: Scalars['String']['output'];
/** @deprecated Use projectName instead */
streamName: Scalars['String']['output'];
/** E-mail address or name of the invited user */
title: Scalars['String']['output'];
@@ -1718,6 +1695,8 @@ export type Project = {
blob?: Maybe<BlobMetadata>;
/** Get the metadata collection of blobs stored for this stream. */
blobs?: Maybe<BlobMetadataCollection>;
/** Get specific project comment/thread by ID */
comment?: Maybe<Comment>;
/** All comment threads in this project */
commentThreads: ProjectCommentCollection;
createdAt: Scalars['DateTime']['output'];
@@ -1727,6 +1706,8 @@ export type Project = {
invitedTeam?: Maybe<Array<PendingStreamCollaborator>>;
/** Returns a specific model by its ID */
model: Model;
/** Retrieve a specific project model by its ID */
modelByName: Model;
/** Return a model tree of children for the specified model name */
modelChildrenTree: Array<ModelsTreeItem>;
/** Returns a flat list of all models */
@@ -1737,6 +1718,9 @@ export type Project = {
*/
modelsTree: ModelsTreeItemCollection;
name: Scalars['String']['output'];
object?: Maybe<Object>;
/** Pending project access requests */
pendingAccessRequests?: Maybe<Array<ProjectAccessRequest>>;
/** Returns a list models that are being created from a file import */
pendingImportedModels: Array<FileUpload>;
/** Active user's role for this project. `null` if request is not authenticated, or the project is not explicitly shared with you. */
@@ -1780,6 +1764,11 @@ export type ProjectBlobsArgs = {
};
export type ProjectCommentArgs = {
id: Scalars['String']['input'];
};
export type ProjectCommentThreadsArgs = {
cursor?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<ProjectCommentsFilter>;
@@ -1792,6 +1781,11 @@ export type ProjectModelArgs = {
};
export type ProjectModelByNameArgs = {
name: Scalars['String']['input'];
};
export type ProjectModelChildrenTreeArgs = {
fullName: Scalars['String']['input'];
};
@@ -1811,6 +1805,11 @@ export type ProjectModelsTreeArgs = {
};
export type ProjectObjectArgs = {
id: Scalars['String']['input'];
};
export type ProjectPendingImportedModelsArgs = {
limit?: InputMaybe<Scalars['Int']['input']>;
};
@@ -1837,6 +1836,38 @@ export type ProjectWebhooksArgs = {
id?: InputMaybe<Scalars['String']['input']>;
};
/** Created when a user requests to become a contributor on a project */
export type ProjectAccessRequest = {
__typename?: 'ProjectAccessRequest';
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
/** Can only be selected if authed user has proper access */
project: Project;
projectId: Scalars['String']['output'];
requester: LimitedUser;
requesterId: Scalars['String']['output'];
};
export type ProjectAccessRequestMutations = {
__typename?: 'ProjectAccessRequestMutations';
/** Request access to a specific project */
create: ProjectAccessRequest;
/** Accept or decline a project access request. Must be a project owner to invoke this. */
use: Project;
};
export type ProjectAccessRequestMutationsCreateArgs = {
projectId: Scalars['String']['input'];
};
export type ProjectAccessRequestMutationsUseArgs = {
accept: Scalars['Boolean']['input'];
requestId: Scalars['String']['input'];
role?: StreamRole;
};
export type ProjectAutomationCreateInput = {
enabled: Scalars['Boolean']['input'];
name: Scalars['String']['input'];
@@ -1899,14 +1930,6 @@ export type ProjectAutomationUpdateInput = {
name?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectAutomationsStatusUpdatedMessage = {
__typename?: 'ProjectAutomationsStatusUpdatedMessage';
model: Model;
project: Project;
status: AutomationsStatus;
version: Version;
};
export type ProjectAutomationsUpdatedMessage = {
__typename?: 'ProjectAutomationsUpdatedMessage';
automation?: Maybe<Automation>;
@@ -1979,6 +2002,7 @@ export type ProjectCreateInput = {
description?: InputMaybe<Scalars['String']['input']>;
name?: InputMaybe<Scalars['String']['input']>;
visibility?: InputMaybe<ProjectVisibility>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
export type ProjectFileImportUpdatedMessage = {
@@ -2087,7 +2111,11 @@ export enum ProjectModelsUpdatedMessageType {
export type ProjectMutations = {
__typename?: 'ProjectMutations';
/** Access request related mutations */
accessRequestMutations: ProjectAccessRequestMutations;
automationMutations: ProjectAutomationMutations;
/** Batch delete projects */
batchDelete: Scalars['Boolean']['output'];
/** Create new project */
create: Project;
/**
@@ -2113,6 +2141,11 @@ export type ProjectMutationsAutomationMutationsArgs = {
};
export type ProjectMutationsBatchDeleteArgs = {
ids: Array<Scalars['String']['input']>;
};
export type ProjectMutationsCreateArgs = {
input?: InputMaybe<ProjectCreateInput>;
};
@@ -2264,7 +2297,10 @@ export type Query = {
adminUsers?: Maybe<AdminUsersListCollection>;
/** Gets a specific app from the server. */
app?: Maybe<ServerApp>;
/** Returns all the publicly available apps on this server. */
/**
* Returns all the publicly available apps on this server.
* @deprecated Part of the old API surface and will be removed in the future.
*/
apps?: Maybe<Array<Maybe<ServerAppListItem>>>;
/** If user is authenticated using an app token, this will describe the app */
authenticatedAsApp?: Maybe<ServerAppListItem>;
@@ -2273,15 +2309,19 @@ export type Query = {
automateFunctions: AutomateFunctionCollection;
/** Part of the automation/function creation handshake mechanism */
automateValidateAuthCode: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.comment instead. */
comment?: Maybe<Comment>;
/**
* This query can be used in the following ways:
* - get all the comments for a stream: **do not pass in any resource identifiers**.
* - get the comments targeting any of a set of provided resources (comments/objects): **pass in an array of resources.**
* @deprecated Use 'commentThreads' fields instead
* @deprecated Use Project/Version/Model 'commentThreads' fields instead
*/
comments?: Maybe<CommentCollection>;
/** All of the discoverable streams of the server */
/**
* All of the discoverable streams of the server
* @deprecated Part of the old API surface and will be removed in the future.
*/
discoverableStreams?: Maybe<StreamCollection>;
/** Get the (limited) profile information of another server user */
otherUser?: Maybe<LimitedUser>;
@@ -2303,20 +2343,29 @@ export type Query = {
/**
* Returns a specific stream. Will throw an authorization error if active user isn't authorized
* to see it, for example, if a stream isn't public and the user doesn't have the appropriate rights.
* @deprecated Part of the old API surface and will be removed in the future. Use Query.project instead.
*/
stream?: Maybe<Stream>;
/** Get authed user's stream access request */
/**
* Get authed user's stream access request
* @deprecated Part of the old API surface and will be removed in the future. Use User.projectAccessRequest instead.
*/
streamAccessRequest?: Maybe<StreamAccessRequest>;
/**
* Look for an invitation to a stream, for the current user (authed or not). If token
* isn't specified, the server will look for any valid invite.
* @deprecated Part of the old API surface and will be removed in the future. Use Query.projectInvite instead.
*/
streamInvite?: Maybe<PendingStreamCollaborator>;
/** Get all invitations to streams that the active user has */
/**
* Get all invitations to streams that the active user has
* @deprecated Part of the old API surface and will be removed in the future. Use User.projectInvites instead.
*/
streamInvites: Array<PendingStreamCollaborator>;
/**
* Returns all streams that the active user is a collaborator on.
* Pass in the `query` parameter to search by name, description or ID.
* @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead.
*/
streams?: Maybe<StreamCollection>;
/**
@@ -2324,7 +2373,10 @@ export type Query = {
* @deprecated To be removed in the near future! Use 'activeUser' to get info about the active user or 'otherUser' to get info about another user.
*/
user?: Maybe<User>;
/** Validate password strength */
/**
* Validate password strength
* @deprecated Part of the old API surface and will be removed in the future.
*/
userPwdStrength: PasswordStrengthCheckResults;
/**
* Search for users and return limited metadata about them, if you have the server:user role.
@@ -2648,13 +2700,22 @@ export enum SortDirection {
export type Stream = {
__typename?: 'Stream';
/** All the recent activity on this stream in chronological order */
/**
* All the recent activity on this stream in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
activity?: Maybe<ActivityCollection>;
allowPublicComments: Scalars['Boolean']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.blob instead. */
blob?: Maybe<BlobMetadata>;
/** Get the metadata collection of blobs stored for this stream. */
/**
* Get the metadata collection of blobs stored for this stream.
* @deprecated Part of the old API surface and will be removed in the future. Use Project.blobs instead.
*/
blobs?: Maybe<BlobMetadataCollection>;
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.model or Project.modelByName instead. */
branch?: Maybe<Branch>;
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.models or Project.modelsTree instead. */
branches?: Maybe<BranchCollection>;
collaborators: Array<StreamCollaborator>;
/**
@@ -2666,18 +2727,27 @@ export type Stream = {
* ...
* }
* ```
* @deprecated Part of the old API surface and will be removed in the future.
*/
commentCount: Scalars['Int']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.version instead. */
commit?: Maybe<Commit>;
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.versions instead. */
commits?: Maybe<CommitCollection>;
createdAt: Scalars['DateTime']['output'];
description?: Maybe<Scalars['String']['output']>;
/** Date when you favorited this stream. `null` if stream isn't viewed from a specific user's perspective or if it isn't favorited. */
favoritedDate?: Maybe<Scalars['DateTime']['output']>;
favoritesCount: Scalars['Int']['output'];
/** Returns a specific file upload that belongs to this stream. */
/**
* Returns a specific file upload that belongs to this stream.
* @deprecated Part of the old API surface and will be removed in the future. Use Project.pendingImportedModels or Model.pendingImportedVersions instead.
*/
fileUpload?: Maybe<FileUpload>;
/** Returns a list of all the file uploads for this stream. */
/**
* Returns a list of all the file uploads for this stream.
* @deprecated Part of the old API surface and will be removed in the future. Use Project.pendingImportedModels or Model.pendingImportedVersions instead.
*/
fileUploads: Array<FileUpload>;
id: Scalars['String']['output'];
/**
@@ -2688,8 +2758,12 @@ export type Stream = {
/** Whether the stream can be viewed by non-contributors */
isPublic: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.object instead. */
object?: Maybe<Object>;
/** Pending stream access requests */
/**
* Pending stream access requests
* @deprecated Part of the old API surface and will be removed in the future. Use Project.pendingAccessRequests instead.
*/
pendingAccessRequests?: Maybe<Array<StreamAccessRequest>>;
/** Collaborators who have been invited, but not yet accepted. */
pendingCollaborators?: Maybe<Array<PendingStreamCollaborator>>;
@@ -2697,6 +2771,7 @@ export type Stream = {
role?: Maybe<Scalars['String']['output']>;
size?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
/** @deprecated Part of the old API surface and will be removed in the future. Use Project.webhooks instead. */
webhooks: WebhookCollection;
};
@@ -2799,6 +2874,7 @@ export type StreamCreateInput = {
name?: InputMaybe<Scalars['String']['input']>;
/** Optionally specify user IDs of users that you want to invite to be contributors to this stream */
withContributors?: InputMaybe<Array<Scalars['String']['input']>>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
export type StreamInviteCreateInput = {
@@ -2847,11 +2923,20 @@ export type Subscription = {
__typename?: 'Subscription';
/** It's lonely in the void. */
_?: Maybe<Scalars['String']['output']>;
/** Subscribe to branch created event */
/**
* Subscribe to branch created event
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead.
*/
branchCreated?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to branch deleted event */
/**
* Subscribe to branch deleted event
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead.
*/
branchDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to branch updated event. */
/**
* Subscribe to branch updated event.
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectModelsUpdated' instead.
*/
branchUpdated?: Maybe<Scalars['JSONObject']['output']>;
/**
* Subscribe to new comment events. There's two ways to use this subscription:
@@ -2867,13 +2952,21 @@ export type Subscription = {
* @deprecated Use projectCommentsUpdated or viewerUserActivityBroadcasted for reply status
*/
commentThreadActivity: CommentThreadActivityMessage;
/** Subscribe to commit created event */
/**
* Subscribe to commit created event
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead.
*/
commitCreated?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit deleted event */
/**
* Subscribe to commit deleted event
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead.
*/
commitDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribe to commit updated event. */
/**
* Subscribe to commit updated event.
* @deprecated Part of the old API surface and will be removed in the future. Use 'projectVersionsUpdated' instead.
*/
commitUpdated?: Maybe<Scalars['JSONObject']['output']>;
projectAutomationsStatusUpdated: ProjectAutomationsStatusUpdatedMessage;
/** Subscribe to updates to automations in the project */
projectAutomationsUpdated: ProjectAutomationsUpdatedMessage;
/**
@@ -2881,7 +2974,10 @@ export type Subscription = {
* updates regarding comments for those resources.
*/
projectCommentsUpdated: ProjectCommentsUpdatedMessage;
/** Subscribe to changes to any of a project's file imports */
/**
* Subscribe to changes to any of a project's file imports
* @deprecated Part of the old API surface and will be removed in the future. Use projectPendingModelsUpdated or projectPendingVersionsUpdated instead.
*/
projectFileImportUpdated: ProjectFileImportUpdatedMessage;
/** Subscribe to changes to a project's models. Optionally specify modelIds to track. */
projectModelsUpdated: ProjectModelsUpdatedMessage;
@@ -2899,20 +2995,28 @@ export type Subscription = {
projectVersionsPreviewGenerated: ProjectVersionsPreviewGeneratedMessage;
/** Subscribe to changes to a project's versions. */
projectVersionsUpdated: ProjectVersionsUpdatedMessage;
/** Subscribes to stream deleted event. Use this in clients/components that pertain only to this stream. */
/**
* Subscribes to stream deleted event. Use this in clients/components that pertain only to this stream.
* @deprecated Part of the old API surface and will be removed in the future. Use projectUpdated instead.
*/
streamDeleted?: Maybe<Scalars['JSONObject']['output']>;
/** Subscribes to stream updated event. Use this in clients/components that pertain only to this stream. */
/**
* Subscribes to stream updated event. Use this in clients/components that pertain only to this stream.
* @deprecated Part of the old API surface and will be removed in the future. Use projectUpdated instead.
*/
streamUpdated?: Maybe<Scalars['JSONObject']['output']>;
/** Track newly added or deleted projects owned by the active user */
userProjectsUpdated: UserProjectsUpdatedMessage;
/**
* Subscribes to new stream added event for your profile. Use this to display an up-to-date list of streams.
* **NOTE**: If someone shares a stream with you, this subscription will be triggered with an extra value of `sharedBy` in the payload.
* @deprecated Part of the old API surface and will be removed in the future. Use userProjectsUpdated instead.
*/
userStreamAdded?: Maybe<Scalars['JSONObject']['output']>;
/**
* Subscribes to stream removed event for your profile. Use this to display an up-to-date list of streams for your profile.
* **NOTE**: If someone revokes your permissions on a stream, this subscription will be triggered with an extra value of `revokedBy` in the payload.
* @deprecated Part of the old API surface and will be removed in the future. Use userProjectsUpdated instead.
*/
userStreamRemoved?: Maybe<Scalars['JSONObject']['output']>;
/**
@@ -2969,11 +3073,6 @@ export type SubscriptionCommitUpdatedArgs = {
};
export type SubscriptionProjectAutomationsStatusUpdatedArgs = {
projectId: Scalars['String']['input'];
};
export type SubscriptionProjectAutomationsUpdatedArgs = {
projectId: Scalars['String']['input'];
};
@@ -3130,7 +3229,10 @@ export type UpdateVersionInput = {
*/
export type User = {
__typename?: 'User';
/** All the recent activity from this user in chronological order */
/**
* All the recent activity from this user in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
activity?: Maybe<ActivityCollection>;
/** Returns a list of your personal api tokens. */
apiTokens: Array<ApiToken>;
@@ -3142,16 +3244,19 @@ export type User = {
/**
* Get commits authored by the user. If requested for another user, then only commits
* from public streams will be returned.
* @deprecated Part of the old API surface and will be removed in the future. Use User.versions instead.
*/
commits?: Maybe<CommitCollection>;
company?: Maybe<Scalars['String']['output']>;
/** Returns the apps you have created. */
createdApps?: Maybe<Array<ServerApp>>;
createdAt?: Maybe<Scalars['DateTime']['output']>;
/** Only returned if API user is the user being requested or an admin */
email?: Maybe<Scalars['String']['output']>;
/**
* All the streams that a active user has favorited.
* Note: You can't use this to retrieve another user's favorite streams.
* @deprecated Part of the old API surface and will be removed in the future.
*/
favoriteStreams: StreamCollection;
/** Whether the user has a pending/active email verification token */
@@ -3162,6 +3267,8 @@ export type User = {
name: Scalars['String']['output'];
notificationPreferences: Scalars['JSONObject']['output'];
profiles?: Maybe<Scalars['JSONObject']['output']>;
/** Get pending project access request, that the user made */
projectAccessRequest?: Maybe<ProjectAccessRequest>;
/** Get all invitations to projects that the active user has */
projectInvites: Array<PendingStreamCollaborator>;
/** Get projects that the user participates in */
@@ -3170,11 +3277,18 @@ export type User = {
/**
* Returns all streams that the user is a collaborator on. If requested for a user, who isn't the
* authenticated user, then this will only return discoverable streams.
* @deprecated Part of the old API surface and will be removed in the future. Use User.projects instead.
*/
streams: StreamCollection;
/** The user's timeline in chronological order */
/**
* The user's timeline in chronological order
* @deprecated Part of the old API surface and will be removed in the future.
*/
timeline?: Maybe<ActivityCollection>;
/** Total amount of favorites attached to streams owned by the user */
/**
* Total amount of favorites attached to streams owned by the user
* @deprecated Part of the old API surface and will be removed in the future.
*/
totalOwnedStreamsFavorites: Scalars['Int']['output'];
verified?: Maybe<Scalars['Boolean']['output']>;
/**
@@ -3220,6 +3334,15 @@ export type UserFavoriteStreamsArgs = {
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
*/
export type UserProjectAccessRequestArgs = {
projectId: Scalars['String']['input'];
};
/**
* Full user type, should only be used in the context of admin operations or
* when a user is reading/writing info about himself
@@ -3315,7 +3438,6 @@ export type UserUpdateInput = {
export type Version = {
__typename?: 'Version';
authorUser?: Maybe<LimitedUser>;
automationStatus?: Maybe<AutomationsStatus>;
automationsStatus?: Maybe<TriggeredAutomationsStatus>;
/** All comment threads in this version */
commentThreads: CommentCollection;
@@ -3365,18 +3487,30 @@ export type VersionCreatedTriggerDefinition = {
export type VersionMutations = {
__typename?: 'VersionMutations';
create: Version;
delete: Scalars['Boolean']['output'];
markReceived: Scalars['Boolean']['output'];
moveToModel: Model;
requestGendoAIRender: Scalars['Boolean']['output'];
update: Version;
};
export type VersionMutationsCreateArgs = {
input: CreateVersionInput;
};
export type VersionMutationsDeleteArgs = {
input: DeleteVersionsInput;
};
export type VersionMutationsMarkReceivedArgs = {
input: MarkReceivedVersionInput;
};
export type VersionMutationsMoveToModelArgs = {
input: MoveVersionsInput;
};
@@ -37,12 +37,12 @@
<NuxtLink
:class="[
active ? 'bg-foundation-focus' : '',
'flex gap-2.5 items-center px-3 py-2.5 text-sm text-foreground cursor-pointer transition mx-1 rounded'
'flex gap-3.5 items-center px-3 py-2.5 text-sm text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="() => (showProfileEditDialog = true)"
@click="toggleSettingsDialog(settingsQueries.user.profile)"
>
<UserAvatar :user="activeUser" size="sm" class="-ml-0.5 mr-px" />
Edit profile
<UserCircleIcon class="w-5 h-5" />
Settings
</NuxtLink>
</MenuItem>
<MenuItem v-if="isAdmin" v-slot="{ active }">
@@ -51,10 +51,10 @@
active ? 'bg-foundation-focus' : '',
'flex gap-3.5 items-center px-3 py-2.5 text-sm text-foreground cursor-pointer transition mx-1 rounded'
]"
@click="goToServerManagement()"
@click="toggleSettingsDialog(settingsQueries.server.general)"
>
<Cog6ToothIcon class="w-5 h-5" />
Server management
<ServerStackIcon class="w-5 h-5" />
Server settings
</NuxtLink>
</MenuItem>
<MenuItem v-slot="{ active }">
@@ -127,11 +127,16 @@
</MenuItems>
</Transition>
</Menu>
<ServerManagementInviteDialog v-model:open="showInviteDialog" />
<UserProfileEditDialog v-model:open="showProfileEditDialog" />
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
<SettingsDialog
v-model:open="showSettingsDialog"
v-model:target-menu-item="settingsDialogTarget"
/>
</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,
@@ -140,54 +145,77 @@ import {
SunIcon,
MoonIcon,
EnvelopeIcon,
Cog6ToothIcon,
CloudArrowDownIcon,
ChatBubbleLeftRightIcon
ChatBubbleLeftRightIcon,
UserCircleIcon,
ServerStackIcon
} 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 { useServerInfo } from '~/lib/core/composables/server'
import { homeRoute, profileRoute, connectorsPageUrl } from '~/lib/common/helpers/route'
import { connectorsPageUrl, settingsQueries } from '~/lib/common/helpers/route'
import type { RouteLocationRaw } from 'vue-router'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
defineProps<{
loginUrl?: RouteLocationRaw
}>()
const route = useRoute()
const { logout } = useAuthManager()
const { activeUser, isGuest } = useActiveUser()
const { isDarkTheme, toggleTheme } = useTheme()
const { serverInfo } = useServerInfo()
const router = useRouter()
const route = useRoute()
const { triggerNotification } = useGlobalToast()
const showInviteDialog = ref(false)
const showProfileEditDialog = ref(false)
const showSettingsDialog = ref(false)
const settingsDialogTarget = ref<string | null>(null)
const menuButtonId = useId()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('md')
const Icon = computed(() => (isDarkTheme.value ? SunIcon : MoonIcon))
const version = computed(() => serverInfo.value?.version)
const isAdmin = computed(() => activeUser.value?.role === Roles.Server.Admin)
const isProfileRoute = computed(() => route.path === profileRoute)
const toggleInviteDialog = () => {
showInviteDialog.value = true
}
const goToServerManagement = () => {
router.push('/server-management')
const toggleSettingsDialog = (target: string) => {
showSettingsDialog.value = true
// On mobile open the modal but dont set the target
settingsDialogTarget.value = !isMobile.value ? target : null
}
watch(
isProfileRoute,
(newVal, oldVal) => {
if (newVal && !oldVal) {
showProfileEditDialog.value = true
void router.replace({ path: homeRoute, force: true }) // in-place replace
const deleteSettingsQuery = (): void => {
const currentQueryParams = { ...route.query }
delete currentQueryParams.settings
router.push({ query: currentQueryParams })
}
onMounted(() => {
const settingsQuery = route.query?.settings
if (settingsQuery && isString(settingsQuery)) {
if (settingsQuery.includes('server') && !isAdmin.value) {
triggerNotification({
type: ToastNotificationType.Danger,
title: "You don't have access to server settings"
})
return
}
},
{ immediate: true }
)
showSettingsDialog.value = true
settingsDialogTarget.value = settingsQuery
deleteSettingsQuery()
}
})
</script>
@@ -216,7 +216,7 @@
>
<template #header>Your first upload</template>
</OnboardingDialogFirstSend>
<ServerManagementInviteDialog
<SettingsServerUserInviteDialog
v-model:open="showServerInviteDialog"
@update:open="(v) => (!v ? markComplete(3) : '')"
/>
@@ -1,22 +0,0 @@
<template>
<div>
<div class="bg-foundation p-4 flex flex-col gap-4 rounded-md shadow-md">
<ServerManagementCardRow
v-for="info in serverInfo"
:key="info.title"
:title="info.title"
:value="info.value"
:cta="info.cta"
:icon="info.icon"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import type { CardInfo } from '~~/lib/server-management/helpers/types'
defineProps<{
serverInfo: CardInfo[]
}>()
</script>
@@ -1,51 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-1 text-foreground-2 text-sm">
<Component :is="icon" class="h-4 w-4" />
<span>{{ title }}</span>
</div>
<div class="flex justify-between items-center gap-4 sm:gap-8">
<span class="text-xl sm:text-2xl font-bold">{{ value }}</span>
<template v-if="cta?.type === 'button'">
<FormButton @click="cta?.action">
{{ cta.label }}
</FormButton>
</template>
<template v-else-if="cta?.type === 'link'">
<FormButton
color="invert"
class="shrink-0"
:icon-right="ArrowTopRightOnSquareIcon"
@click="cta?.action"
>
{{ cta.label }}
</FormButton>
</template>
<template v-else-if="cta?.type === 'text'">
<div
class="flex items-center gap-1 text-xs text-center sm:text-sm opacity-50 shrink-0"
>
<CheckCircleIcon class="h-4 w-4" />
{{ cta.label }}
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { ArrowTopRightOnSquareIcon, CheckCircleIcon } from '@heroicons/vue/24/outline'
import type { ConcreteComponent } from 'vue'
import type { CTA } from '~~/lib/server-management/helpers/types'
defineEmits<{
(e: 'cta-clicked', v: MouseEvent): void
}>()
defineProps<{
title: string
value: string
icon: ConcreteComponent
cta?: CTA
}>()
</script>
@@ -0,0 +1,158 @@
<template>
<LayoutDialog
v-model:open="isOpen"
v-bind="
isMobile ? { title: selectedMenuItem ? selectedMenuItem.title : 'Settings' } : {}
"
fullscreen
:show-back-button="isMobile && !!selectedMenuItem"
@back="targetMenuItem = null"
>
<div class="w-full h-full flex">
<LayoutSidebar
v-if="!isMobile || !selectedMenuItem"
class="w-full md:w-56 lg:w-60 md:p-4 md:pt-6 md:bg-foundation-page md:border-r md:border-outline-3"
>
<LayoutSidebarMenu>
<LayoutSidebarMenuGroup title="Account settings">
<template #title-icon>
<UserIcon class="h-5 w-5" />
</template>
<LayoutSidebarMenuGroupItem
v-for="(sidebarMenuItem, key) in menuItemConfig.user"
:key="key"
:label="sidebarMenuItem.title"
:class="{
'bg-foundation-focus hover:!bg-foundation-focus':
selectedMenuItem?.title === sidebarMenuItem.title
}"
@click="targetMenuItem = `${key}`"
/>
</LayoutSidebarMenuGroup>
<LayoutSidebarMenuGroup v-if="isAdmin" title="Server settings">
<template #title-icon>
<ServerStackIcon class="h-5 w-5" />
</template>
<LayoutSidebarMenuGroupItem
v-for="(sidebarMenuItem, key) in menuItemConfig.server"
:key="key"
:label="sidebarMenuItem.title"
:class="{
'bg-foundation-focus hover:!bg-foundation-focus':
selectedMenuItem?.title === sidebarMenuItem.title
}"
@click="targetMenuItem = `${key}`"
/>
</LayoutSidebarMenuGroup>
</LayoutSidebarMenu>
</LayoutSidebar>
<component
:is="selectedMenuItem.component"
v-if="selectedMenuItem"
:class="[
'bg-foundation md:px-10 md:py-12 md:bg-foundation w-full',
!isMobile && 'simple-scrollbar overflow-y-auto flex-1'
]"
:user="user"
/>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import type { defineComponent } from 'vue'
import SettingsUserProfile from '~/components/settings/user/Profile.vue'
import SettingsUserNotifications from '~/components/settings/user/Notifications.vue'
import SettingsUserDeveloper from '~/components/settings/user/Developer.vue'
import SettingsServerGeneral from '~/components/settings/server/General.vue'
import SettingsServerProjects from '~/components/settings/server/Projects.vue'
import SettingsServerActiveUsers from '~/components/settings/server/ActiveUsers.vue'
import SettingsServerPendingInvitations from '~/components/settings/server/PendingInvitations.vue'
import { useBreakpoints } from '@vueuse/core'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { UserIcon, ServerStackIcon } from '@heroicons/vue/24/outline'
import { settingsQueries } from '~/lib/common/helpers/route'
import { useActiveUser } from '~/lib/auth/composables/activeUser'
import {
LayoutSidebar,
LayoutSidebarMenu,
LayoutSidebarMenuGroup
} from '@speckle/ui-components'
import { Roles } from '@speckle/shared'
type MenuItem = {
title: string
component: ReturnType<typeof defineComponent>
}
const { activeUser: user } = useActiveUser()
const breakpoints = useBreakpoints(TailwindBreakpoints)
const isMobile = breakpoints.smaller('md')
const menuItemConfig = shallowRef<{ [key: string]: { [key: string]: MenuItem } }>({
user: {
[settingsQueries.user.profile]: {
title: 'Profile',
component: SettingsUserProfile
},
[settingsQueries.user.notifications]: {
title: 'Notifications',
component: SettingsUserNotifications
},
[settingsQueries.user.developerSettings]: {
title: 'Developer settings',
component: SettingsUserDeveloper
}
},
server: {
[settingsQueries.server.general]: {
title: 'General',
component: SettingsServerGeneral
},
[settingsQueries.server.projects]: {
title: 'Projects',
component: SettingsServerProjects
},
[settingsQueries.server.activeUsers]: {
title: 'Active users',
component: SettingsServerActiveUsers
},
[settingsQueries.server.pendingInvitations]: {
title: 'Pending invitations',
component: SettingsServerPendingInvitations
}
}
})
const isOpen = defineModel<boolean>('open', { required: true })
const targetMenuItem = defineModel<string | null>('targetMenuItem', { required: true })
const isAdmin = computed(() => user.value?.role === Roles.Server.Admin)
const selectedMenuItem = computed((): MenuItem | null => {
const categories = [menuItemConfig.value.user, menuItemConfig.value.server]
for (const category of categories) {
if (targetMenuItem.value && targetMenuItem.value in category) {
return category[targetMenuItem.value]
}
}
if (!isMobile.value && targetMenuItem.value) {
// Fallback for invalid queries/typos
return targetMenuItem.value.includes('server') && isAdmin.value
? menuItemConfig.value.server.general
: menuItemConfig.value.user.profile
}
return null
})
watch(
() => user.value,
(newVal) => {
if (!newVal) {
isOpen.value = false
}
},
{ immediate: true }
)
</script>
@@ -1,9 +1,11 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col">
<div class="flex flex-col md:flex-row gap-3 md:gap-0 justify-between">
<h2 v-if="subheading" class="h5 font-bold">{{ title }}</h2>
<h1 v-else class="h4 font-bold">{{ title }}</h1>
<div class="flex flex-wrap gap-2">
<h2 v-if="subheading" class="text-xl">{{ title }}</h2>
<h1 v-else class="text-2xl font-semibold hidden md:block">
{{ title }}
</h1>
<div v-if="buttons.length > 0" class="flex flex-wrap gap-2">
<FormButton
v-for="(button, index) in buttons"
:key="index"
@@ -15,9 +17,15 @@
</FormButton>
</div>
</div>
<p class="text-sm max-w-5xl">
<slot></slot>
<p
v-if="text"
class="text-sm pt-2 md:pt-4 text-secondary-2"
:class="{ 'pt-6': subheading }"
>
{{ text }}
</p>
<hr v-if="!subheading" class="my-6 md:my-10" />
<slot />
</div>
</template>
@@ -0,0 +1,218 @@
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Active users" text="Manage server members" />
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
<div class="relative w-full md:max-w-md mt-6 md:mt-0">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!search"
placeholder="Search users"
class="rounded-md border border-outline-3"
:model-value="bind.modelValue.value"
v-on="on"
/>
</div>
<FormButton :icon-left="UserPlusIcon" @click="toggleInviteDialog">
Invite
</FormButton>
</div>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'email', header: 'Email', classes: 'col-span-3 truncate' },
{ id: 'emailState', header: 'Email state', classes: 'col-span-2' },
{ id: 'company', header: 'Company', classes: 'col-span-2 truncate' },
{ id: 'role', header: 'Role', classes: 'col-span-2' }
]"
:items="users"
:buttons="[{ icon: TrashIcon, label: 'Delete', action: openUserDeleteDialog }]"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar v-if="isUser(item)" :user="item" />
<span class="truncate">
{{ isUser(item) ? item.name : '' }}
</span>
</div>
</template>
<template #email="{ item }">
{{ isUser(item) ? item.email : '' }}
</template>
<template #emailState="{ item }">
<div class="flex items-center gap-2 select-none">
<template v-if="isUser(item) && item.verified">
<CheckCircleIcon class="h-4 w-4 text-primary" />
<span>Verified</span>
</template>
<template v-else>
<ExclamationCircleIcon class="h-4 w-4 text-danger" />
<span>Not verified</span>
</template>
</div>
</template>
<template #company="{ item }">
{{ isUser(item) ? item.company : '' }}
</template>
<template #role="{ item }">
<FormSelectServerRoles
:allow-guest="isGuestMode"
allow-admin
allow-archived
:model-value="isUser(item) ? item.role : undefined"
:disabled="isUser(item) && isCurrentUser(item)"
fully-control-value
@update:model-value="(newRoleValue) => isUser(item) && !isArray(newRoleValue) && newRoleValue && openChangeUserRoleDialog(item, newRoleValue as ServerRoles)"
/>
</template>
</LayoutTable>
<CommonLoadingBar v-if="loading && !users?.length" loading />
<InfiniteLoading
v-if="users?.length"
:settings="{ identifier: infiniteLoaderId }"
class="-mt-24 -mb-24"
@infinite="infiniteLoad"
/>
<SettingsServerUserDeleteDialog
v-model:open="showUserDeleteDialog"
:user="userToModify"
:result-variables="resultVariables"
/>
<SettingsServerUserChangeRoleDialog
v-model:open="showChangeUserRoleDialog"
:user="userToModify"
:old-role="oldRole"
:new-role="newRole"
hide-closer
/>
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { isArray } from 'lodash-es'
import { useLogger } from '~~/composables/logging'
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import type { Nullable, ServerRoles, Optional } from '@speckle/shared'
import { getUsersQuery } from '~~/lib/server-management/graphql/queries'
import type { ItemType, UserItem } from '~~/lib/server-management/helpers/types'
import { isUser } from '~~/lib/server-management/helpers/utils'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import {
MagnifyingGlassIcon,
ExclamationCircleIcon,
CheckCircleIcon,
TrashIcon,
UserPlusIcon
} from '@heroicons/vue/24/outline'
import { useServerInfo } from '~~/lib/core/composables/server'
import { useDebouncedTextInput } from '@speckle/ui-components'
const search = defineModel<string>('search')
const logger = useLogger()
const { activeUser } = useActiveUser()
const { isGuestMode } = useServerInfo()
const { on, bind } = useDebouncedTextInput({ model: search })
const userToModify: Ref<Nullable<UserItem>> = ref(null)
const showUserDeleteDialog = ref(false)
const showChangeUserRoleDialog = ref(false)
const newRole = ref<ServerRoles>()
const infiniteLoaderId = ref('')
const showInviteDialog = ref(false)
const queryVariables = computed(() => ({
limit: 50,
query: search.value
}))
const {
result: extraPagesResult,
fetchMore: fetchMorePages,
variables: resultVariables,
onResult,
loading
} = useQuery(getUsersQuery, queryVariables)
const oldRole = computed(() => userToModify.value?.role as Optional<ServerRoles>)
const moreToLoad = computed(
() =>
!extraPagesResult.value?.admin?.userList ||
extraPagesResult.value.admin.userList.items.length <
extraPagesResult.value.admin.userList.totalCount
)
const users = computed(() => extraPagesResult.value?.admin.userList.items || [])
const isCurrentUser = (userItem: UserItem) => {
return userItem.id === activeUser.value?.id
}
const openUserDeleteDialog = (item: ItemType) => {
if (isUser(item)) {
userToModify.value = item
showUserDeleteDialog.value = true
}
}
const openChangeUserRoleDialog = (user: UserItem, newRoleValue: ServerRoles) => {
if (user.role === newRoleValue) {
return
}
userToModify.value = user
newRole.value = newRoleValue
showChangeUserRoleDialog.value = true
}
const infiniteLoad = async (state: InfiniteLoaderState) => {
const cursor = extraPagesResult.value?.admin?.userList.cursor || null
if (!moreToLoad.value || !cursor) return state.complete()
try {
await fetchMorePages({
variables: {
cursor
}
})
} catch (e) {
logger.error(e)
state.error()
return
}
state.loaded()
if (!moreToLoad.value) {
state.complete()
}
}
const calculateLoaderId = () => {
infiniteLoaderId.value = resultVariables.value?.query || ''
}
const toggleInviteDialog = () => {
showInviteDialog.value = true
}
onResult(calculateLoaderId)
</script>
@@ -1,68 +1,78 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Edit Settings</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-4">
<FormTextInput
v-model="name"
label="This server's public name"
name="serverName"
color="foundation"
placeholder="Server name"
show-label
:show-required="true"
:rules="requiredRule"
:type="'text'"
/>
<FormTextArea
v-model="description"
color="foundation"
label="Description"
name="description"
placeholder="Description"
show-label
/>
<FormTextInput
v-model="company"
color="foundation"
label="Owner"
name="owner"
placeholder="Owner"
show-label
/>
<FormTextInput
v-model="adminContact"
color="foundation"
label="Admin Email"
name="adminEmail"
placeholder="Admin Email"
show-label
:type="'email'"
/>
<FormTextInput
v-model="termsOfService"
color="foundation"
label="Url pointing to the terms of service page"
name="terms"
show-label
/>
<div class="text-sm flex flex-col gap-2 mt-2">
<FormCheckbox
v-model="inviteOnly"
label="Invite only mode - Only users with an invitation will be able to join"
name="inviteOnly"
show-label
/>
<FormCheckbox
v-model="guestModeEnabled"
label="Guest mode - Enables the 'Guest' server role, which allows users to only contribute to projects that they're invited to"
name="guestModeEnabled"
show-label
/>
</div>
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="General" text="Manage general server information" />
<div class="flex flex-col space-y-6">
<SettingsSectionHeader title="Server details" subheading />
<form class="flex flex-col gap-2" @submit="onSubmit">
<div class="flex flex-col gap-4">
<FormTextInput
v-model="name"
label="Public name"
name="serverName"
color="foundation"
placeholder="Server name"
show-label
:show-required="true"
:rules="requiredRule"
type="text"
/>
<FormTextArea
v-model="description"
color="foundation"
label="Description"
name="description"
placeholder="Description"
show-label
/>
<FormTextInput
v-model="company"
color="foundation"
label="Owner"
name="owner"
placeholder="Owner"
show-label
/>
<FormTextInput
v-model="adminContact"
color="foundation"
label="Admin email"
name="adminEmail"
placeholder="Admin email"
show-label
type="email"
/>
<FormTextInput
v-model="termsOfService"
color="foundation"
label="URL to the Terms of Service"
name="terms"
show-label
/>
<div class="text-sm flex flex-col gap-2 mt-2">
<FormCheckbox
v-model="inviteOnly"
label="Invite only mode - Only users with an invitation will be able to join"
name="inviteOnly"
show-label
/>
<FormCheckbox
v-model="guestModeEnabled"
label="Guest mode - Enables the 'Guest' server role, which allows users to only contribute to projects that they're invited to"
name="guestModeEnabled"
show-label
/>
</div>
<div>
<FormButton color="default" @click="onSubmit">Save changes</FormButton>
</div>
</div>
</form>
</div>
</form>
</LayoutDialog>
<hr class="my-6 md:my-10" />
<SettingsServerGeneralVersion />
</div>
</section>
</template>
<script setup lang="ts">
@@ -71,11 +81,9 @@ import { useForm } from 'vee-validate'
import { isRequired } from '~~/lib/common/helpers/validation'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import {
LayoutDialog,
FormTextInput,
FormTextArea,
useFormCheckboxModel,
type LayoutDialogButton
useFormCheckboxModel
} from '@speckle/ui-components'
import { useLogger } from '~~/composables/logging'
import {
@@ -115,21 +123,6 @@ const { model: inviteOnly, isChecked: isInviteOnlyChecked } = useFormCheckboxMod
const { model: guestModeEnabled, isChecked: isGuestModeChecked } =
useFormCheckboxModel()
const isOpen = defineModel<boolean>('open', { required: true })
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Cancel',
props: { color: 'secondary', fullWidth: true, outline: true },
onClick: () => (isOpen.value = false)
},
{
text: 'Save',
props: { color: 'default', fullWidth: true, outline: false },
onClick: onSubmit
}
])
const requiredRule = [isRequired]
const updateServerInfoAndCache = async (
@@ -181,7 +174,6 @@ const onSubmit = handleSubmit(async () => {
title: 'Successfully saved',
description: 'Your server settings have been saved.'
})
isOpen.value = false
} else {
logger.error(result && result.errors)
triggerNotification({
@@ -192,8 +184,7 @@ const onSubmit = handleSubmit(async () => {
}
})
watch(isOpen, (newVal, oldVal) => {
if (!newVal || oldVal) return
onBeforeMount(() => {
if (!result.value?.serverInfo) return
name.value = result.value.serverInfo.name
@@ -1,100 +1,94 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink to="/server-management" name="Server Management"></HeaderNavLink>
<HeaderNavLink
to="/server-management/pending-invitations"
name="Pending Invitations"
></HeaderNavLink>
</Portal>
<div class="flex justify-between items-center mb-8">
<h1 class="h4 font-bold">Pending Invitations</h1>
<FormButton :icon-left="UserPlusIcon" @click="toggleInviteDialog">
Invite
</FormButton>
</div>
<FormTextInput
size="lg"
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!searchString"
placeholder="Search Invitations"
class="rounded-md border border-outline-3"
@update:model-value="debounceSearchUpdate"
@change="($event) => searchUpdateHandler($event.value)"
/>
<LayoutTable
class="mt-8"
:columns="[
{ id: 'email', header: 'Email', classes: 'col-span-5 truncate' },
{ id: 'invitedBy', header: 'Invited By', classes: 'col-span-4' },
{ id: 'resend', header: '', classes: 'col-span-3' }
]"
:items="invites"
:buttons="[
{ icon: TrashIcon, label: 'Delete', action: openDeleteInvitationDialog }
]"
>
<template #email="{ item }">
{{ isInvite(item) ? item.email : '' }}
</template>
<template #invitedBy="{ item }">
<div class="flex items-center gap-2 py-1">
<UserAvatar v-if="isInvite(item)" :user="item.invitedBy" />
<span class="truncate">
{{ isInvite(item) ? item.invitedBy.name : '' }}
</span>
</div>
</template>
<template #resend="{ item }">
<FormButton
:link="true"
:class="{
'font-semibold': true,
'text-primary': !successfullyResentInvites.includes(item.id),
'text-foreground': successfullyResentInvites.includes(item.id)
}"
:disabled="successfullyResentInvites.includes(item.id)"
@click="resendInvitation(item as InviteItem)"
>
{{
successfullyResentInvites.includes(item.id)
? 'Invitation Resent'
: 'Resend Invitation'
}}
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
title="Pending invitations"
text="And overview of all your pending invititations"
/>
<div class="flex flex-col-reverse md:flex-row">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!search"
placeholder="Search invitations"
class="rounded-md border border-outline-3 md:max-w-md mt-6 md:mt-0"
:model-value="bind.modelValue.value"
v-on="on"
/>
<FormButton :icon-left="UserPlusIcon" @click="toggleInviteDialog">
Invite
</FormButton>
</template>
</LayoutTable>
</div>
<ServerManagementDeleteInvitationDialog
v-model:open="showDeleteInvitationDialog"
:invite="inviteToModify"
:result-variables="resultVariables"
/>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'email', header: 'Email', classes: 'col-span-5 truncate' },
{ id: 'invitedBy', header: 'Invited by', classes: 'col-span-4' },
{ id: 'resend', header: 'Resend', classes: 'col-span-3' }
]"
:items="invites"
:buttons="[
{ icon: TrashIcon, label: 'Delete', action: openDeleteInvitationDialog }
]"
>
<template #email="{ item }">
{{ isInvite(item) ? item.email : '' }}
</template>
<CommonLoadingBar v-if="loading && !invites?.length" loading />
<template #invitedBy="{ item }">
<div class="flex items-center gap-2 py-1">
<UserAvatar v-if="isInvite(item)" :user="item.invitedBy" />
<span class="truncate">
{{ isInvite(item) ? item.invitedBy.name : '' }}
</span>
</div>
</template>
<InfiniteLoading
v-if="invites?.length"
:settings="{ identifier: infiniteLoaderId }"
class="py-4"
@infinite="infiniteLoad"
/>
<ServerManagementInviteDialog v-model:open="showInviteDialog" />
</div>
<template #resend="{ item }">
<FormButton
:link="true"
:class="{
'font-semibold': true,
'text-primary': !successfullyResentInvites.includes(item.id),
'text-foreground': successfullyResentInvites.includes(item.id)
}"
:disabled="successfullyResentInvites.includes(item.id)"
@click="resendInvitation(item as InviteItem)"
>
{{
successfullyResentInvites.includes(item.id)
? 'Invitation resent'
: 'Resend invitation'
}}
</FormButton>
</template>
</LayoutTable>
<SettingsServerPendingInvitationsDeleteDialog
v-model:open="showDeleteInvitationDialog"
:invite="inviteToModify"
:result-variables="resultVariables"
/>
<CommonLoadingBar v-if="loading && !invites?.length" loading />
<InfiniteLoading
v-if="invites?.length"
:settings="{ identifier: infiniteLoaderId }"
class="py-4"
@infinite="infiniteLoad"
/>
<SettingsServerUserInviteDialog v-model:open="showInviteDialog" />
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import { useQuery, useMutation } from '@vue/apollo-composable'
import { MagnifyingGlassIcon, TrashIcon, UserPlusIcon } from '@heroicons/vue/24/outline'
import type { ItemType, InviteItem } from '~~/lib/server-management/helpers/types'
@@ -107,21 +101,16 @@ import {
convertThrowIntoFetchResult,
getFirstErrorMessage
} from '~~/lib/common/helpers/graphql'
import { useDebouncedTextInput } from '@speckle/ui-components'
useHead({
title: 'Pending Invitations'
})
definePageMeta({
middleware: ['admin']
})
const search = defineModel<string>('search')
const logger = useLogger()
const { triggerNotification } = useGlobalToast()
const { mutate: resendInvitationMutation } = useMutation(adminResendInviteMutation)
const { on, bind } = useDebouncedTextInput({ model: search })
const inviteToModify = ref<InviteItem | null>(null)
const searchString = ref('')
const showDeleteInvitationDialog = ref(false)
const infiniteLoaderId = ref('')
const successfullyResentInvites = ref<string[]>([])
@@ -135,7 +124,7 @@ const {
loading
} = useQuery(getInvitesQuery, () => ({
limit: 50,
query: searchString.value
query: search.value
}))
const moreToLoad = computed(
@@ -201,12 +190,6 @@ const infiniteLoad = async (state: InfiniteLoaderState) => {
}
}
const searchUpdateHandler = (value: string) => {
searchString.value = value
}
const debounceSearchUpdate = debounce(searchUpdateHandler, 500)
const calculateLoaderId = () => {
infiniteLoaderId.value = resultVariables.value?.query || ''
}
@@ -0,0 +1,186 @@
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
title="Projects"
text="Manage projects across the server"
/>
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
<div class="relative w-full md:max-w-md mt-6 md:mt-0">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!search"
placeholder="Search projects"
class="rounded-md border border-outline-3 md:max-w-md mt-6 md:mt-0"
:model-value="bind.modelValue.value"
v-on="on"
/>
</div>
<FormButton :icon-left="PlusIcon" @click="openNewProject = true">
New
</FormButton>
</div>
<LayoutTable
class="mt-6 md:mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'type', header: 'Type', classes: 'col-span-1' },
{ id: 'created', header: 'Created', classes: 'col-span-2' },
{ id: 'modified', header: 'Modified', classes: 'col-span-2' },
{ id: 'models', header: 'Models', classes: 'col-span-1' },
{ id: 'versions', header: 'Versions', classes: 'col-span-1' },
{ id: 'contributors', header: 'Contributors', classes: 'col-span-2' }
]"
:items="projects"
:buttons="[
{ icon: TrashIcon, label: 'Delete', action: openProjectDeleteDialog }
]"
:on-row-click="handleProjectClick"
>
<template #name="{ item }">
{{ isProject(item) ? item.name : '' }}
</template>
<template #type="{ item }">
<div class="capitalize">
{{ isProject(item) ? item.visibility.toLowerCase() : '' }}
</div>
</template>
<template #created="{ item }">
<div class="text-xs">
{{ formattedFullDate(item.createdAt) }}
</div>
</template>
<template #modified="{ item }">
<div class="text-xs">
{{ formattedFullDate(item.updatedAt) }}
</div>
</template>
<template #models="{ item }">
<div class="text-xs">
{{ isProject(item) ? item.models.totalCount : '' }}
</div>
</template>
<template #versions="{ item }">
<div class="text-xs">
{{ isProject(item) ? item.versions.totalCount : '' }}
</div>
</template>
<template #contributors="{ item }">
<div v-if="isProject(item)" class="py-1">
<UserAvatarGroup :users="item.team.map((t) => t.user)" :max-count="3" />
</div>
</template>
</LayoutTable>
<CommonLoadingBar v-if="loading && !projects?.length" loading />
<InfiniteLoading
v-if="projects?.length"
:settings="{ identifier: infiniteLoaderId }"
class="py-4"
@infinite="infiniteLoad"
/>
<SettingsServerProjectDeleteDialog
v-model:open="showProjectDeleteDialog"
:project="projectToModify"
title="Delete project"
:result-variables="resultVariables"
/>
<ProjectsAddDialog v-model:open="openNewProject" />
</div>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { MagnifyingGlassIcon, TrashIcon, PlusIcon } from '@heroicons/vue/24/outline'
import { getProjectsQuery } from '~~/lib/server-management/graphql/queries'
import type { ItemType, ProjectItem } from '~~/lib/server-management/helpers/types'
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import { isProject } from '~~/lib/server-management/helpers/utils'
import { useDebouncedTextInput } from '@speckle/ui-components'
const search = defineModel<string>('search')
const { on, bind } = useDebouncedTextInput({ model: search })
const logger = useLogger()
const router = useRouter()
const projectToModify = ref<ProjectItem | null>(null)
const showProjectDeleteDialog = ref(false)
const infiniteLoaderId = ref('')
const openNewProject = ref(false)
const {
result: extraPagesResult,
fetchMore: fetchMorePages,
variables: resultVariables,
onResult,
loading
} = useQuery(getProjectsQuery, () => ({
limit: 50,
query: search.value
}))
const moreToLoad = computed(
() =>
!extraPagesResult.value?.admin?.projectList ||
extraPagesResult.value.admin.projectList.items.length <
extraPagesResult.value.admin.projectList.totalCount
)
const projects = computed(() => extraPagesResult.value?.admin.projectList.items || [])
const openProjectDeleteDialog = (item: ItemType) => {
if (isProject(item)) {
projectToModify.value = item
showProjectDeleteDialog.value = true
}
}
const handleProjectClick = (item: ItemType) => {
router.push(`/projects/${item.id}`)
}
const infiniteLoad = async (state: InfiniteLoaderState) => {
const cursor = extraPagesResult.value?.admin?.projectList.cursor || null
if (!moreToLoad.value || !cursor) return state.complete()
try {
await fetchMorePages({
variables: {
cursor
}
})
} catch (e) {
logger.error(e)
state.error()
return
}
state.loaded()
if (!moreToLoad.value) {
state.complete()
}
}
const calculateLoaderId = () => {
infiniteLoaderId.value = resultVariables.value?.query || ''
}
onResult(calculateLoaderId)
</script>
@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col space-y-6">
<h2 class="text-xl">Speckle Version</h2>
<div class="flex items-center">
<div class="w-[50%]">
<p class="text-sm">
<span class="font-semibold">Current version:</span>
{{ currentVersion }}
</p>
<p v-if="!isLatestVersion" class="text-sm pt-2">New version available</p>
</div>
<div class="flex justify-end w-[50%]">
<FormButton
color="secondary"
:disabled="isLatestVersion"
@click="openGithubReleasePage"
>
{{ isLatestVersion ? 'You are up to date' : `Update to ${latestVersion}` }}
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { serverManagementDataQuery } from '~~/lib/server-management/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { ref, computed } from 'vue'
interface GithubRelease {
url: string
assets_url: string
upload_url: string
html_url: string
id: number
node_id: string
tag_name: string
}
const logger = useLogger()
const { result } = useQuery(serverManagementDataQuery)
const latestVersion = ref<string | null>(null)
const currentVersion = computed(() => result.value?.serverInfo.version)
const isLatestVersion = computed(() => {
return (
!latestVersion.value ||
currentVersion.value === latestVersion.value ||
currentVersion.value === 'dev' ||
currentVersion.value?.includes('alpha') ||
currentVersion.value === 'N/A'
)
})
const openGithubReleasePage = () => {
window.open('https://github.com/specklesystems/speckle-server/releases', '_blank')
}
async function getLatestVersion(): Promise<string | null> {
try {
const response: Response = await fetch(
'https://api.github.com/repos/specklesystems/speckle-server/releases/latest'
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
} else {
const data = (await response.json()) as GithubRelease
return data.tag_name
}
} catch (err) {
logger.error(err)
return null
}
}
latestVersion.value = await getLatestVersion()
</script>
@@ -1,6 +1,6 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Delete Invitation</template>
<template #header>Delete invitation</template>
<div class="flex flex-col gap-6 text-sm text-foreground">
<p>Are you sure you want to delete the selected invitation?</p>
<div v-if="invite" class="flex flex-col gap-2">
@@ -1,6 +1,6 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Change Role</template>
<template #header>Change role</template>
<div class="flex flex-col gap-6 text-sm text-foreground">
<p>
Are you sure you want to
@@ -117,7 +117,7 @@ const changeUserRoleConfirmed = async () => {
const dialogButtons = computed((): LayoutDialogButton[] => [
{
text: 'Change Role',
text: 'Change role',
props: { color: 'danger', fullWidth: true },
onClick: changeUserRoleConfirmed
},
@@ -1,6 +1,6 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Delete User</template>
<template #header>Delete user</template>
<div class="flex flex-col gap-6">
<p>
Are you sure you want to
@@ -0,0 +1,387 @@
<template>
<section>
<div class="md:max-w-5xl md:mx-auto pb-6 md:pb-0">
<div class="flex flex-col">
<SettingsSectionHeader
title="Developer settings"
text="Manage your tokens and authorized app"
/>
<div class="flex flex-col gap-6 md:gap-12">
<div class="flex flex-col">
<SettingsSectionHeader
title="Explore GraphQL"
class="md:gap-0"
subheading
:buttons="[
{
props: {
color: 'secondary',
target: '_blank',
external: true,
iconLeft: BookOpenIcon
},
onClick: goToExplorer,
label: 'Open docs'
}
]"
/>
</div>
<hr />
<div class="flex flex-col gap-4">
<SettingsSectionHeader
title="Access tokens"
subheading
:buttons="[
{
props: {
color: 'secondary',
to: 'https://speckle.guide/dev/tokens.html',
iconLeft: BookOpenIcon,
target: '_blank',
external: true
},
label: 'Open docs'
},
{
props: {
iconLeft: PlusIcon,
onClick: openCreateTokenDialog
},
label: 'New token'
}
]"
>
<p class="text-sm pt-6 md:pt-4">
Personal Access Tokens can be used to access the Speckle API on this
server; they function like ordinary OAuth access tokens. Use them in
your scripts or apps!
<strong>
Treat them like a password: do not post them anywhere where they could
be accessed by others (e.g., public repos).
</strong>
</p>
</SettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'id', header: 'ID', classes: 'col-span-2' },
{
id: 'scope',
header: 'Scope',
classes: 'col-span-7 whitespace-break-spaces text-xs'
}
]"
:items="tokens"
:buttons="[
{
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
textColor: 'danger'
}
]"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #id="{ item }">
<span class="rounded text-xs font-mono bg-foundation-page p-2">
{{ item.id }}
</span>
</template>
<template #scope="{ item }">
{{ getItemScopes(item) }}
</template>
</LayoutTable>
</div>
<hr />
<div class="flex flex-col gap-4">
<SettingsSectionHeader
subheading
title="Applications"
:buttons="[
{
props: {
color: 'secondary',
to: 'https://speckle.guide/dev/apps.html',
target: '_blank',
external: true,
iconLeft: BookOpenIcon
},
label: 'Open docs'
},
{
props: {
onClick: openCreateApplicationDialog,
iconLeft: PlusIcon
},
label: 'New application'
}
]"
>
<p class="text-sm pt-6 md:pt-4">
Register and manage third-party Speckle Apps that, once authorised by a
user on this server, can act on their behalf.
</p>
</SettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3' },
{ id: 'id', header: 'ID', classes: 'col-span-2' },
{
id: 'scope',
header: 'Scope',
classes: 'col-span-7 whitespace-break-spaces text-xs'
}
]"
:items="applications"
:buttons="[
{
icon: LockOpenIcon,
label: 'Reveal Secret',
action: openRevealSecretDialog,
textColor: 'primary'
},
{
icon: PencilIcon,
label: 'Edit',
action: openEditApplicationDialog,
textColor: 'primary'
},
{
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
textColor: 'danger'
}
]"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #id="{ item }">
<span class="rounded text-xs font-mono bg-foundation-page p-2">
{{ item.id }}
</span>
</template>
<template #scope="{ item }">
{{ getItemScopes(item) }}
</template>
</LayoutTable>
</div>
<hr />
<div class="flex flex-col gap-4">
<SettingsSectionHeader
subheading
title="Authorized Apps"
:buttons="[
{
props: {
color: 'secondary',
to: 'https://speckle.guide/dev/apps.html',
target: '_blank',
external: true,
iconLeft: BookOpenIcon
},
label: 'Open docs'
}
]"
>
<p class="text-sm pt-6 md:pt-4">
Here you can review the apps that you have granted access to. If
something looks suspicious, revoke the access.
</p>
</SettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 ' },
{ id: 'author', header: 'Author', classes: 'col-span-3 ' },
{
id: 'description',
header: 'Description',
classes: 'col-span-6 !pt-1.5'
}
]"
:items="authorizedApps"
:buttons="[
{
icon: XMarkIcon,
label: 'Revoke Access',
action: openDeleteDialog,
textColor: 'danger'
}
]"
row-items-align="stretch"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #author="{ item }">
<div class="flex space-x-2 items-center">
<template v-if="item.author">
<UserAvatar :user="item.author" />
<span>{{ item.author.name }}</span>
</template>
<template v-else>
<HeaderLogoBlock minimal no-link />
<span>Speckle</span>
</template>
</div>
</template>
<template #description="{ item }">
{{ item.description }}
</template>
</LayoutTable>
</div>
</div>
<SettingsUserDeveloperCreateTokenDialog
v-model:open="showCreateTokenDialog"
@token-created="(token) => handleTokenCreated(token)"
/>
<SettingsUserDeveloperDeleteDialog
v-model:open="showDeleteDialog"
:item="itemToModify"
/>
<SettingsUserDeveloperCreateEditApplicationDialog
v-model:open="showCreateEditApplicationDialog"
:application="(itemToModify as ApplicationItem)"
@application-created="handleApplicationCreated"
/>
<SettingsUserDeveloperRevealSecretDialog
v-model:open="showRevealSecretDialog"
:application="itemToModify && 'secret' in itemToModify ? itemToModify : null"
/>
<SettingsUserDeveloperCreateTokenSuccessDialog
v-model:open="showCreateTokenSuccessDialog"
:token="tokenSuccess"
/>
<SettingsUserDeveloperCreateApplicationSuccessDialog
v-model:open="showCreateApplicationSuccessDialog"
:application="(itemToModify as ApplicationItem)"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import {
PlusIcon,
BookOpenIcon,
TrashIcon,
PencilIcon,
LockOpenIcon,
XMarkIcon
} from '@heroicons/vue/24/outline'
import type {
TokenItem,
ApplicationItem,
AuthorizedAppItem
} from '~~/lib/developer-settings/helpers/types'
import {
developerSettingsAccessTokensQuery,
developerSettingsApplicationsQuery,
developerSettingsAuthorizedAppsQuery
} from '~~/lib/developer-settings/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
useHead({
title: 'Developer Settings'
})
const apiOrigin = useApiOrigin()
const { result: tokensResult, refetch: refetchTokens } = useQuery(
developerSettingsAccessTokensQuery
)
const { result: applicationsResult, refetch: refetchApplications } = useQuery(
developerSettingsApplicationsQuery
)
const { result: authorizedAppsResult } = useQuery(developerSettingsAuthorizedAppsQuery)
const itemToModify = ref<TokenItem | ApplicationItem | AuthorizedAppItem | null>(null)
const tokenSuccess = ref('')
const showCreateTokenDialog = ref(false)
const showCreateTokenSuccessDialog = ref(false)
const showCreateApplicationSuccessDialog = ref(false)
const showDeleteDialog = ref(false)
const showCreateEditApplicationDialog = ref(false)
const showRevealSecretDialog = ref(false)
const tokens = computed<TokenItem[]>(() => {
return (
tokensResult.value?.activeUser?.apiTokens?.filter(
(token): token is TokenItem => token !== null
) || []
)
})
const applications = computed<ApplicationItem[]>(() => {
return applicationsResult.value?.activeUser?.createdApps || []
})
const authorizedApps = computed(() =>
(authorizedAppsResult.value?.activeUser?.authorizedApps || []).filter(
(app) => app.id !== 'spklwebapp'
)
)
const openDeleteDialog = (item: TokenItem | ApplicationItem | AuthorizedAppItem) => {
itemToModify.value = item
showDeleteDialog.value = true
}
const openCreateApplicationDialog = () => {
itemToModify.value = null
showCreateEditApplicationDialog.value = true
}
const openCreateTokenDialog = () => {
showCreateTokenDialog.value = true
}
const openEditApplicationDialog = (item: ApplicationItem) => {
itemToModify.value = item
showCreateEditApplicationDialog.value = true
}
const openRevealSecretDialog = (item: ApplicationItem) => {
itemToModify.value = item
showRevealSecretDialog.value = true
}
const handleTokenCreated = (token: string) => {
refetchTokens()
tokenSuccess.value = token
showCreateTokenSuccessDialog.value = true
}
const handleApplicationCreated = (applicationId: string) => {
refetchApplications()?.then(() => {
const newApplication = applications.value.find((app) => app.id === applicationId)
if (newApplication) {
itemToModify.value = newApplication
showCreateApplicationSuccessDialog.value = true
}
})
}
const goToExplorer = () => {
if (!import.meta.client) return
window.location.href = new URL('/explorer', apiOrigin).toString()
}
const getItemScopes = (item: TokenItem | ApplicationItem): string => {
return item.scopes
? item.scopes
.map(
(event, index, array) => `"${event}"${index < array.length - 1 ? ',' : ''}`
)
.join(' ')
: 'No scopes available'
}
</script>
@@ -1,54 +1,59 @@
<template>
<LayoutDialogSection title="Notification preferences" border-t border-b>
<template #icon>
<BellIcon class="h-full w-full" />
</template>
<table class="table-auto w-full rounded-t overflow-hidden">
<thead class="text-foreground-1">
<tr>
<th class="bg-primary-muted py-2 px-4">Notification type</th>
<th
v-for="channel in notificationChannels"
:key="channel"
class="bg-primary-muted text-right py-2 px-4"
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader
title="Notifications"
text="Manage your notification preferences"
/>
<table class="table-auto w-full rounded-t overflow-hidden">
<thead class="text-foreground-1">
<tr>
<th class="pb-4 font-semibold text-sm text-left">Notification type</th>
<th
v-for="channel in notificationChannels"
:key="channel"
class="text-right font-semibold pb-4 text-sm"
>
{{ capitalize(channel) }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="[type, settings] in Object.entries(localPreferences)"
:key="type"
class="border-t"
>
{{ capitalize(channel) }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="[type, settings] in Object.entries(localPreferences)" :key="type">
<td class="px-4 pb-1 pt-2 text-xs sm:text-sm">
{{ notificationTypeMapping[type] || 'Unknown' }}
</td>
<td
v-for="channel in notificationChannels"
:key="channel"
class="flex justify-end pt-2 pr-4"
>
<FormCheckbox
:name="`${type} (${channel})`"
:disabled="loading"
hide-label
:model-value="settings[channel] || undefined"
@update:model-value="
($event) => onUpdate({ value: !!$event, type, channel })
"
/>
</td>
</tr>
</tbody>
</table>
</LayoutDialogSection>
<td class="text-xs sm:text-sm py-4">
{{ notificationTypeMapping[type] || 'Unknown' }}
</td>
<td
v-for="channel in notificationChannels"
:key="channel"
class="flex justify-end py-4"
>
<FormCheckbox
:name="`${type} (${channel})`"
:disabled="loading"
hide-label
:model-value="settings[channel] || undefined"
@update:model-value="
($event) => onUpdate({ value: !!$event, type, channel })
"
/>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<script setup lang="ts">
import { LayoutDialogSection } from '@speckle/ui-components'
import { BellIcon } from '@heroicons/vue/24/outline'
import { capitalize, cloneDeep } from 'lodash-es'
import { graphql } from '~~/lib/common/generated/gql'
import type { UserProfileEditDialogNotificationPreferences_UserFragment } from '~~/lib/common/generated/gql/graphql'
import { useUpdateNotificationPreferences } from '~~/lib/user/composables/management'
import type { NotificationPreferences } from '~~/lib/user/helpers/components'
import type { UserProfileEditDialogNotificationPreferences_UserFragment } from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment UserProfileEditDialogNotificationPreferences_User on User {
@@ -0,0 +1,29 @@
<template>
<section>
<div class="md:max-w-xl md:mx-auto pb-6 md:pb-0">
<SettingsSectionHeader title="Profile" text="Manage your profile information" />
<SettingsUserProfileDetails :user="user" />
<hr class="my-6 md:my-10" />
<SettingsUserProfileChangePassword :user="user" />
<hr class="my-6 md:my-10" />
<SettingsUserProfileDeleteAccount :user="user" />
</div>
</section>
</template>
<script setup lang="ts">
import { graphql } from '~~/lib/common/generated/gql'
import type { SettingsUserProfile_UserFragment } from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment SettingsUserProfile_User on User {
...UserProfileEditDialogChangePassword_User
...UserProfileEditDialogDeleteAccount_User
...UserProfileEditDialogBio_User
}
`)
defineProps<{
user: SettingsUserProfile_UserFragment
}>()
</script>
@@ -1,18 +1,18 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Create Application</template>
<template #header>Create application</template>
<div class="flex flex-col gap-4 text-sm text-foreground">
<div class="flex flex-col gap-3">
<h6 class="h6 font-bold text-center">Your new app is ready</h6>
<div class="grid grid-cols-2 gap-x-6 gap-y-3 py-2 text-sm max-w-xs mx-auto">
<div class="flex items-center">App Id:</div>
<div class="flex items-center">App id:</div>
<div class="w-40">
<CommonClipboardInputWithToast
v-if="props.application?.id"
:value="props.application?.id"
/>
</div>
<div class="flex items-center">App Secret:</div>
<div class="flex items-center">App secret:</div>
<div class="w-40">
<CommonClipboardInputWithToast
v-if="props.application?.secret"
@@ -1,12 +1,7 @@
<template>
<LayoutDialog
v-model:open="isOpen"
max-width="sm"
:buttons="dialogButtons"
prevent-close-on-click-outside
>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>
{{ props.application ? 'Edit Application' : 'Create Application' }}
{{ props.application ? 'Edit application' : 'Create application' }}
</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-6">
@@ -15,6 +10,7 @@
label="Name"
help="The name of your app"
name="hookName"
color="foundation"
show-required
:rules="[isRequired]"
show-label
@@ -41,6 +37,7 @@
help="After authentication, the users will be redirected (together with an access token) to this URL."
show-required
name="redirectUrl"
color="foundation"
show-label
:rules="[isRequired, isUrl]"
type="text"
@@ -50,6 +47,7 @@
label="Description"
help="A short description of your application."
name="description"
color="foundation"
show-label
type="text"
/>
@@ -1,11 +1,6 @@
<template>
<LayoutDialog
v-model:open="isOpen"
max-width="sm"
:buttons="dialogButtons"
prevent-close-on-click-outside
>
<template #header>Create Token</template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Create token</template>
<form @submit="onSubmit">
<div class="flex flex-col gap-6">
<FormTextInput
@@ -13,6 +8,8 @@
label="Name"
help="A name to remember this token by. For example, the name of the script or application you're planning to use it in!"
name="hookName"
placeholder="Token name"
color="foundation"
:rules="[isRequired]"
show-required
show-label
@@ -1,18 +1,13 @@
<template>
<LayoutDialog
v-model:open="isOpen"
max-width="sm"
:buttons="dialogButtons"
prevent-close-on-click-outside
>
<template #header>Reveal Application Secret</template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Reveal application secret</template>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 py-2 text-sm">
<div class="text-center sm:text-right font-bold sm:font-normal">App Name:</div>
<p class="truncate text-center sm:text-left">{{ props.application?.name }}</p>
<div
class="text-center sm:text-right flex items-center justify-center sm:justify-end font-bold sm:font-normal"
>
App Secret:
App secret:
</div>
<div class="w-44 mx-auto sm:ml-0">
<CommonClipboardInputWithToast
@@ -0,0 +1,37 @@
<template>
<div class="flex flex-col space-y-6">
<SettingsSectionHeader title="Change password" subheading />
<p class="text-sm">
Press the button below to start the password reset process.
<br />
Once pressed, you will receive an e-mail with further instructions.
</p>
<div>
<FormButton color="default" @click="onClick">Reset password</FormButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { UserProfileEditDialogChangePassword_UserFragment } from '~~/lib/common/generated/gql/graphql'
import { graphql } from '~~/lib/common/generated/gql'
import { usePasswordReset } from '~~/lib/auth/composables/passwordReset'
graphql(`
fragment UserProfileEditDialogChangePassword_User on User {
id
email
}
`)
const { sendResetEmail } = usePasswordReset()
const props = defineProps<{
user: UserProfileEditDialogChangePassword_UserFragment
}>()
const onClick = async () => {
const email = props.user.email
if (!email) return
await sendResetEmail(email)
}
</script>
@@ -0,0 +1,34 @@
<template>
<div>
<SettingsUserProfileDeleteAccountDialog
v-model:open="showDeleteDialog"
:user="user"
/>
<div class="flex flex-col space-y-6">
<SettingsSectionHeader title="Delete account" subheading />
<div class="rounded border bg-foundation-page border-outline-3 text-sm py-4 px-6">
We will delete all projects where you are the sole owner, and any associated
data. We will ask you to type in your email address and press the delete button.
</div>
<div>
<FormButton color="danger" @click="toggleDeleteDialog">
Delete account
</FormButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { UserProfileEditDialogDeleteAccount_UserFragment } from '~~/lib/common/generated/gql/graphql'
defineProps<{
user: UserProfileEditDialogDeleteAccount_UserFragment
}>()
const showDeleteDialog = ref(false)
function toggleDeleteDialog() {
showDeleteDialog.value = true
}
</script>
@@ -1,8 +1,5 @@
<template>
<LayoutDialogSection border-b title="Delete Account" title-color="danger">
<template #icon>
<TrashIcon class="h-full w-full" />
</template>
<LayoutDialog v-model:open="isOpen" title="Delete account" max-width="md">
<form class="flex flex-col gap-2" @submit="onDelete">
<div
class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 py-3 px-4 bg-danger-lighter dark:bg-danger-darker rounded-md select-none mb-4"
@@ -42,11 +39,10 @@
</FormButton>
</div>
</form>
</LayoutDialogSection>
</LayoutDialog>
</template>
<script setup lang="ts">
import { LayoutDialogSection } from '@speckle/ui-components'
import { TrashIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
import { useForm } from 'vee-validate'
import type { GenericValidateFunction } from 'vee-validate'
import { graphql } from '~~/lib/common/generated/gql'
@@ -74,6 +70,8 @@ const props = defineProps<{
user: UserProfileEditDialogDeleteAccount_UserFragment
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const { handleSubmit, errors } = useForm<{ deleteEmail: string }>()
const { mutate, loading } = useDeleteAccount()
@@ -1,41 +1,38 @@
<template>
<div class="flex flex-col space-y-4 mb-8">
<div class="flex flex-col space-y-4">
<div class="flex flex-col md:flex-row gap-2 sm:gap-8 items-center">
<div class="w-full md:w-4/12">
<UserProfileEditDialogAvatar :user="user" size="xxl" />
</div>
<div class="flex flex-col space-y-4 w-full md:w-9/12">
<FormTextInput
v-model="name"
color="foundation"
label="Name"
name="name"
placeholder="John Doe"
:custom-icon="UserIcon"
show-label
show-required
:rules="[isRequired, isStringOfLength({ maxLength: 512 })]"
@change="save"
/>
<FormTextInput
v-model="company"
color="foundation"
label="Company"
name="company"
placeholder="Example Ltd."
:custom-icon="BriefcaseIcon"
show-label
:rules="[isStringOfLength({ maxLength: 512 })]"
@change="save"
/>
</div>
<div class="flex flex-col gap-y-4">
<SettingsSectionHeader title="Your details" subheading />
<div class="grid md:grid-cols-2 pt-4">
<div class="flex h-full items-center justify-center">
<SettingsUserProfileEditAvatar :user="user" size="xxl" />
</div>
<div class="pt-6 md:pt-0">
<FormTextInput
v-model="name"
class="pt-2 pb-1"
color="foundation"
label="Name"
name="name"
placeholder="John Doe"
show-label
:rules="[isRequired, isStringOfLength({ maxLength: 512 })]"
@change="save"
/>
<hr class="mt-4 mb-2" />
<FormTextInput
v-model="company"
color="foundation"
label="Company"
name="company"
placeholder="Example Ltd."
show-label
:rules="[isStringOfLength({ maxLength: 512 })]"
@change="save"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { UserIcon, BriefcaseIcon } from '@heroicons/vue/24/solid'
import { debounce } from 'lodash-es'
import { graphql } from '~~/lib/common/generated/gql'
import type {
@@ -1,82 +0,0 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="md">
<template #header>Edit Profile</template>
<div v-if="user" class="flex flex-col text-foreground">
<UserProfileEditDialogBio :user="user" />
<UserProfileEditDialogNotificationPreferences :user="user" />
<LayoutDialogSection
title="Developer Settings"
:button="developerSettingsButton"
border-b
>
<template #icon>
<CodeBracketIcon class="h-full w-full" />
</template>
</LayoutDialogSection>
<UserProfileEditDialogChangePassword :user="user" />
<UserProfileEditDialogDeleteAccount :user="user" @deleted="isOpen = false" />
<div class="text-xs text-foreground-2 mt-4">
User ID:
<CommonTextLink size="xs" no-underline @click="copyUserId">
#{{ user.id }}
</CommonTextLink>
<template v-if="distinctId">
|
<CommonTextLink size="xs" no-underline @click="copyDistinctId">
{{ distinctId }}
</CommonTextLink>
</template>
</div>
</div>
</LayoutDialog>
</template>
<script setup lang="ts">
import { CodeBracketIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
import { useActiveUser } from '~/lib/auth/composables/activeUser'
type FormButtonColor =
| 'default'
| 'invert'
| 'danger'
| 'warning'
| 'success'
| 'card'
| 'secondary'
| 'info'
const emit = defineEmits<{
(e: 'update:open', val: boolean): void
}>()
const props = defineProps<{
open: boolean
}>()
const { activeUser: user, distinctId } = useActiveUser()
const { copy } = useClipboard()
const isOpen = computed({
get: () => !!(props.open && user.value),
set: (newVal) => emit('update:open', newVal)
})
const developerSettingsButton = computed(() => ({
text: 'Manage',
color: 'default' as FormButtonColor,
to: '/developer-settings/',
iconRight: ChevronRightIcon,
onClick: () => {
isOpen.value = false
}
}))
const copyUserId = () => {
if (!user.value) return
copy(user.value.id)
}
const copyDistinctId = () => {
if (!distinctId.value) return
copy(distinctId.value)
}
</script>
@@ -1,31 +0,0 @@
<template>
<LayoutDialogSection border-b title="Change Password" title-color="default">
<template #icon>
<LockClosedIcon class="h-full w-full" />
</template>
<div class="flex flex-col space-y-4">
<div>
Press the button below to start the password reset process. Once pressed, you
will receive an e-mail with further instructions.
</div>
<div class="flex justify-end">
<FormButton color="default" @click="onClick">Reset password</FormButton>
</div>
</div>
</LayoutDialogSection>
</template>
<script setup lang="ts">
import { LayoutDialogSection } from '@speckle/ui-components'
import { LockClosedIcon } from '@heroicons/vue/24/outline'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { usePasswordReset } from '~~/lib/auth/composables/passwordReset'
const { activeUser } = useActiveUser()
const { sendResetEmail } = usePasswordReset()
const onClick = async () => {
const email = activeUser.value?.email
if (!email) return
await sendResetEmail(email)
}
</script>
@@ -83,10 +83,12 @@ const documents = {
"\n fragment ProjectsDashboardFilled on ProjectCollection {\n items {\n ...ProjectDashboardItem\n }\n }\n": types.ProjectsDashboardFilledFragmentDoc,
"\n fragment ProjectsInviteBanner on PendingStreamCollaborator {\n id\n invitedBy {\n ...LimitedUserAvatar\n }\n projectId\n projectName\n token\n user {\n id\n }\n }\n": types.ProjectsInviteBannerFragmentDoc,
"\n fragment ProjectsInviteBanners on User {\n projectInvites {\n ...ProjectsInviteBanner\n }\n }\n": types.ProjectsInviteBannersFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n": types.UserProfileEditDialogBio_UserFragmentDoc,
"\n fragment UserProfileEditDialogDeleteAccount_User on User {\n id\n email\n }\n": types.UserProfileEditDialogDeleteAccount_UserFragmentDoc,
"\n fragment UserProfileEditDialogNotificationPreferences_User on User {\n id\n notificationPreferences\n }\n": types.UserProfileEditDialogNotificationPreferences_UserFragmentDoc,
"\n fragment SettingsUserProfile_User on User {\n ...UserProfileEditDialogChangePassword_User\n ...UserProfileEditDialogDeleteAccount_User\n ...UserProfileEditDialogBio_User\n }\n": types.SettingsUserProfile_UserFragmentDoc,
"\n fragment UserProfileEditDialogChangePassword_User on User {\n id\n email\n }\n": types.UserProfileEditDialogChangePassword_UserFragmentDoc,
"\n fragment UserProfileEditDialogDeleteAccount_User on User {\n id\n email\n }\n": types.UserProfileEditDialogDeleteAccount_UserFragmentDoc,
"\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n": types.UserProfileEditDialogBio_UserFragmentDoc,
"\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n": types.UserProfileEditDialogAvatar_UserFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
"\n fragment ViewerCommentsListItem on Comment {\n id\n rawText\n archived\n author {\n ...LimitedUserAvatar\n }\n createdAt\n viewedAt\n replies {\n totalCount\n cursor\n items {\n ...ViewerCommentsReplyItem\n }\n }\n replyAuthors(limit: 4) {\n totalCount\n items {\n ...FormUsersSelectItem\n }\n }\n resources {\n resourceId\n resourceType\n }\n }\n": types.ViewerCommentsListItemFragmentDoc,
@@ -538,11 +540,15 @@ export function graphql(source: "\n fragment ProjectsInviteBanners on User {\n
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"];
export function graphql(source: "\n fragment UserProfileEditDialogNotificationPreferences_User on User {\n id\n notificationPreferences\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogNotificationPreferences_User on User {\n id\n notificationPreferences\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n"];
export function graphql(source: "\n fragment SettingsUserProfile_User on User {\n ...UserProfileEditDialogChangePassword_User\n ...UserProfileEditDialogDeleteAccount_User\n ...UserProfileEditDialogBio_User\n }\n"): (typeof documents)["\n fragment SettingsUserProfile_User on User {\n ...UserProfileEditDialogChangePassword_User\n ...UserProfileEditDialogDeleteAccount_User\n ...UserProfileEditDialogBio_User\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogChangePassword_User on User {\n id\n email\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogChangePassword_User on User {\n id\n email\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -550,7 +556,11 @@ export function graphql(source: "\n fragment UserProfileEditDialogDeleteAccount
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogNotificationPreferences_User on User {\n id\n notificationPreferences\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogNotificationPreferences_User on User {\n id\n notificationPreferences\n }\n"];
export function graphql(source: "\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogBio_User on User {\n id\n name\n company\n bio\n ...UserProfileEditDialogAvatar_User\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"): (typeof documents)["\n fragment UserProfileEditDialogAvatar_User on User {\n id\n avatar\n ...ActiveUserAvatar\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -14,6 +14,24 @@ export const downloadManagerRoute = '/download-manager'
export const serverManagementRoute = '/server-management'
export const connectorsPageUrl = 'https://speckle.systems/features/connectors/'
export const settingsQueries: {
[key: string]: {
[key: string]: string
}
} = {
user: {
profile: 'user/profile',
notifications: 'user/notifications',
developerSettings: 'user/developer-settings'
},
server: {
general: 'server/general',
projects: 'server/projects',
activeUsers: 'server/active-users',
pendingInvitations: 'server/pending-invitations'
}
}
export const projectRoute = (
id: string,
tab?: 'models' | 'discussions' | 'automations' | 'settings'
+45
View File
@@ -159,6 +159,51 @@ export default defineNuxtConfig({
'access-control-allow-methods': 'GET',
'Access-Control-Expose-Headers': '*'
}
},
// Redirect old settings pages
'/server-management/projects': {
redirect: {
to: '/?settings=server/projects',
statusCode: 301
}
},
'/server-management/active-users': {
redirect: {
to: '/?settings=server/active-users',
statusCode: 301
}
},
'/server-management/pending-invitations': {
redirect: {
to: '/?settings=server/pending-invitations',
statusCode: 301
}
},
'/server-management': {
redirect: {
to: '/?settings=server/general',
statusCode: 301
}
},
'/profile': {
redirect: {
to: '/?settings=user/profile',
statusCode: 301
}
},
// Redirect settings 'route' to homepage with added query
'/settings': { redirect: '/?settings=user/profile' },
'/settings/user/profile': { redirect: '/?settings=user/profile' },
'/settings/user/notifications': { redirect: '/?settings=user/notifications' },
'/settings/user/developer-settings': {
redirect: '/?settings=user/developer-settings'
},
'/settings/server/general': { redirect: '/?settings=server/general' },
'/settings/server/projects': { redirect: '/?settings=server/projects' },
'/settings/server/active-users': { redirect: '/?settings=server/active-users' },
'/settings/server/pending-invitations': {
redirect: '/?settings=server/pending-invitations'
}
},
@@ -1,381 +0,0 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink
to="/developer-settings/"
name="Developer Settings"
></HeaderNavLink>
</Portal>
<div class="flex flex-col gap-16">
<DeveloperSettingsSectionHeader
title="Developer Settings"
:buttons="[
{
props: {
target: '_blank',
external: true
},
onClick: goToExplorer,
label: 'Explore GraphQL'
}
]"
>
Heads up! The sections below are intended for developers.
</DeveloperSettingsSectionHeader>
<div class="flex flex-col gap-4">
<DeveloperSettingsSectionHeader
title="Access Tokens"
subheading
:buttons="[
{
props: {
color: 'invert',
to: 'https://speckle.guide/dev/tokens.html',
iconLeft: BookOpenIcon,
target: '_blank',
external: true
},
label: 'Open Docs'
},
{
props: {
iconLeft: PlusIcon,
onClick: openCreateTokenDialog
},
label: 'New Token'
}
]"
>
Personal Access Tokens can be used to access the Speckle API on this server;
they function like ordinary OAuth access tokens. Use them in your scripts or
apps!
<strong>
Treat them like a password: do not post them anywhere where they could be
accessed by others (e.g., public repos).
</strong>
</DeveloperSettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'id', header: 'ID', classes: 'col-span-2' },
{
id: 'scope',
header: 'Scope',
classes: 'col-span-7 whitespace-break-spaces text-xs'
}
]"
:items="tokens"
:buttons="[
{
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
textColor: 'danger'
}
]"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #id="{ item }">
<span class="rounded text-xs font-mono bg-foundation-page p-2">
{{ item.id }}
</span>
</template>
<template #scope="{ item }">
<div>
{{
item.scopes
? item.scopes
.map(
(event, index, array) =>
`"${event}"${index < array.length - 1 ? ',' : ''}`
)
.join(' ')
: 'No scopes available'
}}
</div>
</template>
</LayoutTable>
</div>
<div class="flex flex-col gap-4">
<DeveloperSettingsSectionHeader
subheading
title="Applications"
:buttons="[
{
props: {
color: 'invert',
to: 'https://speckle.guide/dev/apps.html',
target: '_blank',
external: true,
iconLeft: BookOpenIcon
},
label: 'Open Docs'
},
{
props: {
onClick: openCreateApplicationDialog,
iconLeft: PlusIcon
},
label: 'New Application'
}
]"
>
Register and manage third-party Speckle Apps that, once authorised by a user
on this server, can act on their behalf.
</DeveloperSettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3' },
{ id: 'id', header: 'ID', classes: 'col-span-2' },
{
id: 'scope',
header: 'Scope',
classes: 'col-span-7 whitespace-break-spaces text-xs'
}
]"
:items="applications"
:buttons="[
{
icon: LockOpenIcon,
label: 'Reveal Secret',
action: openRevealSecretDialog,
textColor: 'primary'
},
{
icon: PencilIcon,
label: 'Edit',
action: openEditApplicationDialog,
textColor: 'primary'
},
{
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
textColor: 'danger'
}
]"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #id="{ item }">
<span class="rounded text-xs font-mono bg-foundation-page p-2">
{{ item.id }}
</span>
</template>
<template #scope="{ item }">
<div>
{{
item.scopes
.map(
(event, index, array) =>
`"${event.name}"${index < array.length - 1 ? ',' : ''}`
)
.join(' ')
}}
</div>
</template>
</LayoutTable>
</div>
<div class="flex flex-col gap-4">
<DeveloperSettingsSectionHeader
subheading
title="Authorized Apps"
:buttons="[
{
props: {
color: 'invert',
to: 'https://speckle.guide/dev/apps.html',
target: '_blank',
external: true,
iconLeft: BookOpenIcon
},
label: 'Open Docs'
}
]"
>
Here you can review the apps that you have granted access to. If something
looks suspicious, revoke the access.
</DeveloperSettingsSectionHeader>
<LayoutTable
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 ' },
{ id: 'author', header: 'Author', classes: 'col-span-3 ' },
{ id: 'description', header: 'Description', classes: 'col-span-6 !pt-1.5' }
]"
:items="authorizedApps"
:buttons="[
{
icon: XMarkIcon,
label: 'Revoke Access',
action: openDeleteDialog,
textColor: 'danger'
}
]"
row-items-align="stretch"
>
<template #name="{ item }">
{{ item.name }}
</template>
<template #author="{ item }">
<div class="flex space-x-2 items-center">
<template v-if="item.author">
<UserAvatar :user="item.author" />
<span>{{ item.author.name }}</span>
</template>
<template v-else>
<HeaderLogoBlock minimal no-link />
<span>Speckle</span>
</template>
</div>
</template>
<template #description="{ item }">
{{ item.description }}
</template>
</LayoutTable>
</div>
</div>
<DeveloperSettingsCreateTokenDialog
v-model:open="showCreateTokenDialog"
@token-created="(token) => handleTokenCreated(token)"
/>
<DeveloperSettingsDeleteDialog
v-model:open="showDeleteDialog"
:item="itemToModify"
/>
<DeveloperSettingsCreateEditApplicationDialog
v-model:open="showCreateEditApplicationDialog"
:application="(itemToModify as ApplicationItem)"
@application-created="handleApplicationCreated"
/>
<DeveloperSettingsRevealSecretDialog
v-model:open="showRevealSecretDialog"
:application="itemToModify && 'secret' in itemToModify ? itemToModify : null"
/>
<DeveloperSettingsCreateTokenSuccessDialog
v-model:open="showCreateTokenSuccessDialog"
:token="tokenSuccess"
/>
<DeveloperSettingsCreateApplicationSuccessDialog
v-model:open="showCreateApplicationSuccessDialog"
:application="(itemToModify as ApplicationItem)"
/>
</div>
</template>
<script setup lang="ts">
import {
PlusIcon,
BookOpenIcon,
TrashIcon,
PencilIcon,
LockOpenIcon,
XMarkIcon
} from '@heroicons/vue/24/outline'
import type {
TokenItem,
ApplicationItem,
AuthorizedAppItem
} from '~~/lib/developer-settings/helpers/types'
import {
developerSettingsAccessTokensQuery,
developerSettingsApplicationsQuery,
developerSettingsAuthorizedAppsQuery
} from '~~/lib/developer-settings/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
useHead({
title: 'Developer Settings'
})
const apiOrigin = useApiOrigin()
const { result: tokensResult, refetch: refetchTokens } = useQuery(
developerSettingsAccessTokensQuery
)
const { result: applicationsResult, refetch: refetchApplications } = useQuery(
developerSettingsApplicationsQuery
)
const { result: authorizedAppsResult } = useQuery(developerSettingsAuthorizedAppsQuery)
const itemToModify = ref<TokenItem | ApplicationItem | AuthorizedAppItem | null>(null)
const tokenSuccess = ref('')
const showCreateTokenDialog = ref(false)
const showCreateTokenSuccessDialog = ref(false)
const showCreateApplicationSuccessDialog = ref(false)
const showDeleteDialog = ref(false)
const showCreateEditApplicationDialog = ref(false)
const showRevealSecretDialog = ref(false)
const tokens = computed<TokenItem[]>(() => {
return (
tokensResult.value?.activeUser?.apiTokens?.filter(
(token): token is TokenItem => token !== null
) || []
)
})
const applications = computed<ApplicationItem[]>(() => {
return applicationsResult.value?.activeUser?.createdApps || []
})
const authorizedApps = computed(() =>
(authorizedAppsResult.value?.activeUser?.authorizedApps || []).filter(
(app) => app.id !== 'spklwebapp'
)
)
const openDeleteDialog = (item: TokenItem | ApplicationItem | AuthorizedAppItem) => {
itemToModify.value = item
showDeleteDialog.value = true
}
const openCreateApplicationDialog = () => {
itemToModify.value = null
showCreateEditApplicationDialog.value = true
}
const openCreateTokenDialog = () => {
showCreateTokenDialog.value = true
}
const openEditApplicationDialog = (item: ApplicationItem) => {
itemToModify.value = item
showCreateEditApplicationDialog.value = true
}
const openRevealSecretDialog = (item: ApplicationItem) => {
itemToModify.value = item
showRevealSecretDialog.value = true
}
const handleTokenCreated = (token: string) => {
refetchTokens()
tokenSuccess.value = token
showCreateTokenSuccessDialog.value = true
}
const handleApplicationCreated = (applicationId: string) => {
refetchApplications()?.then(() => {
const newApplication = applications.value.find((app) => app.id === applicationId)
if (newApplication) {
itemToModify.value = newApplication
showCreateApplicationSuccessDialog.value = true
}
})
}
const goToExplorer = () => {
if (!import.meta.client) return
window.location.href = new URL('/explorer', apiOrigin).toString()
}
</script>
@@ -1,234 +0,0 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink to="/server-management" name="Server Management"></HeaderNavLink>
<HeaderNavLink
to="/server-management/active-users"
name="Active Users"
></HeaderNavLink>
</Portal>
<div class="flex justify-between items-center mb-8">
<h1 class="h4 font-bold">Active Users</h1>
<FormButton :icon-left="UserPlusIcon" @click="toggleInviteDialog">
Invite
</FormButton>
</div>
<FormTextInput
size="lg"
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!searchString"
placeholder="Search Users"
class="rounded-md border border-outline-3"
@update:model-value="debounceSearchUpdate"
@change="($event) => searchUpdateHandler($event.value)"
/>
<LayoutTable
class="mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'email', header: 'Email', classes: 'col-span-3 truncate' },
{ id: 'emailState', header: 'Email State', classes: 'col-span-2' },
{ id: 'company', header: 'Company', classes: 'col-span-2 truncate' },
{ id: 'role', header: 'Role', classes: 'col-span-2' }
]"
:items="users"
:buttons="[{ icon: TrashIcon, label: 'Delete', action: openUserDeleteDialog }]"
>
<template #name="{ item }">
<div class="flex items-center gap-2">
<UserAvatar v-if="isUser(item)" :user="item" />
<span class="truncate">
{{ isUser(item) ? item.name : '' }}
</span>
</div>
</template>
<template #email="{ item }">
{{ isUser(item) ? item.email : '' }}
</template>
<template #emailState="{ item }">
<div class="flex items-center gap-2 select-none">
<template v-if="isUser(item) && item.verified">
<CheckCircleIcon class="h-4 w-4 text-primary" />
<span>Verified</span>
</template>
<template v-else>
<ExclamationCircleIcon class="h-4 w-4 text-danger" />
<span>Not verified</span>
</template>
</div>
</template>
<template #company="{ item }">
{{ isUser(item) ? item.company : '' }}
</template>
<template #role="{ item }">
<FormSelectServerRoles
:allow-guest="isGuestMode"
allow-admin
allow-archived
:model-value="isUser(item) ? item.role : undefined"
:disabled="isUser(item) && isCurrentUser(item)"
fully-control-value
@update:model-value="(newRoleValue) => isUser(item) && !isArray(newRoleValue) && newRoleValue && openChangeUserRoleDialog(item, newRoleValue as ServerRoles)"
/>
</template>
</LayoutTable>
<CommonLoadingBar v-if="loading && !users?.length" loading />
<InfiniteLoading
v-if="users?.length"
:settings="{ identifier: infiniteLoaderId }"
class="-mt-24 -mb-24"
@infinite="infiniteLoad"
/>
<ServerManagementDeleteUserDialog
v-model:open="showUserDeleteDialog"
:user="userToModify"
:result-variables="resultVariables"
/>
<ServerManagementChangeUserRoleDialog
v-model:open="showChangeUserRoleDialog"
:user="userToModify"
:old-role="oldRole"
:new-role="newRole"
hide-closer
/>
<ServerManagementInviteDialog v-model:open="showInviteDialog" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { debounce, isArray } from 'lodash-es'
import { useLogger } from '~~/composables/logging'
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import type { Nullable, ServerRoles, Optional } from '@speckle/shared'
import { getUsersQuery } from '~~/lib/server-management/graphql/queries'
import type { ItemType, UserItem } from '~~/lib/server-management/helpers/types'
import { isUser } from '~~/lib/server-management/helpers/utils'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import {
MagnifyingGlassIcon,
ExclamationCircleIcon,
CheckCircleIcon,
TrashIcon,
UserPlusIcon
} from '@heroicons/vue/24/outline'
import { useServerInfo } from '~~/lib/core/composables/server'
useHead({
title: 'Active Users'
})
definePageMeta({
middleware: ['admin']
})
const logger = useLogger()
const { activeUser } = useActiveUser()
const { isGuestMode } = useServerInfo()
const userToModify: Ref<Nullable<UserItem>> = ref(null)
const searchString = ref('')
const showUserDeleteDialog = ref(false)
const showChangeUserRoleDialog = ref(false)
const newRole = ref<ServerRoles>()
const infiniteLoaderId = ref('')
const showInviteDialog = ref(false)
const queryVariables = computed(() => ({
limit: 50,
query: searchString.value
}))
const {
result: extraPagesResult,
fetchMore: fetchMorePages,
variables: resultVariables,
onResult,
loading
} = useQuery(getUsersQuery, queryVariables)
const oldRole = computed(() => userToModify.value?.role as Optional<ServerRoles>)
const moreToLoad = computed(
() =>
!extraPagesResult.value?.admin?.userList ||
extraPagesResult.value.admin.userList.items.length <
extraPagesResult.value.admin.userList.totalCount
)
const users = computed(() => extraPagesResult.value?.admin.userList.items || [])
const isCurrentUser = (userItem: UserItem) => {
return userItem.id === activeUser.value?.id
}
const openUserDeleteDialog = (item: ItemType) => {
if (isUser(item)) {
userToModify.value = item
showUserDeleteDialog.value = true
}
}
const openChangeUserRoleDialog = (user: UserItem, newRoleValue: ServerRoles) => {
if (user.role === newRoleValue) {
return
}
userToModify.value = user
newRole.value = newRoleValue
showChangeUserRoleDialog.value = true
}
const infiniteLoad = async (state: InfiniteLoaderState) => {
const cursor = extraPagesResult.value?.admin?.userList.cursor || null
if (!moreToLoad.value || !cursor) return state.complete()
try {
await fetchMorePages({
variables: {
cursor
}
})
} catch (e) {
logger.error(e)
state.error()
return
}
state.loaded()
if (!moreToLoad.value) {
state.complete()
}
}
const searchUpdateHandler = (value: string) => {
searchString.value = value
}
const debounceSearchUpdate = debounce(searchUpdateHandler, 500)
const calculateLoaderId = () => {
infiniteLoaderId.value = resultVariables.value?.query || ''
}
const toggleInviteDialog = () => {
showInviteDialog.value = true
}
onResult(calculateLoaderId)
</script>
@@ -1,171 +0,0 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink to="/server-management" name="Server Management"></HeaderNavLink>
</Portal>
<div
class="flex flex-col md:flex-row space-y-2 space-x-2 justify-between mb-4 md:items-center"
>
<div>
<h5 class="h4 font-bold">Your server at a glance</h5>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-10">
<ServerManagementCard
:server-info="serverData"
@cta-clicked="showDialog = true"
/>
<ServerManagementSettingsDialog v-model:open="showDialog" />
<ServerManagementCard :server-info="userData" />
<ServerManagementCard :server-info="projectData" />
</div>
</div>
</template>
<script setup lang="ts">
import { serverManagementDataQuery } from '~~/lib/server-management/graphql/queries'
import { useQuery } from '@vue/apollo-composable'
import { ref, computed } from 'vue'
import type { CardInfo } from '~~/lib/server-management/helpers/types'
import {
ServerIcon,
UsersIcon,
EnvelopeIcon,
ChartBarIcon,
HomeIcon
} from '@heroicons/vue/24/solid'
import { useRouter } from 'vue-router'
interface GithubRelease {
url: string
assets_url: string
upload_url: string
html_url: string
id: number
node_id: string
tag_name: string
}
useHead({
title: 'Server Management'
})
definePageMeta({
middleware: ['admin']
})
const logger = useLogger()
const router = useRouter()
const { result } = useQuery(serverManagementDataQuery)
const showDialog = ref(false)
const latestVersion = ref<string | null>(null)
const currentVersion = computed(() => result.value?.serverInfo.version)
const isLatestVersion = computed(() => {
return (
!latestVersion.value ||
currentVersion.value === latestVersion.value ||
currentVersion.value === 'dev' ||
currentVersion.value?.includes('alpha') ||
currentVersion.value === 'N/A'
)
})
const serverData = computed((): CardInfo[] => [
{
title: 'Server',
value: result.value?.serverInfo.name || 'N/A',
icon: ServerIcon,
cta: {
type: 'button',
label: 'Edit',
action: () => {
showDialog.value = true
}
}
},
{
title: 'Speckle Version',
value: currentVersion.value || 'N/A',
icon: ChartBarIcon,
cta: !isLatestVersion.value
? {
type: 'link',
label: 'Update is available',
action: openGithubReleasePage
}
: {
type: 'text',
label: 'Up-to-date'
}
}
])
const userData = computed((): CardInfo[] => [
{
title: 'Active users',
value: result.value?.admin.userList.totalCount?.toString() || 'N/A',
icon: UsersIcon,
cta: {
type: 'button',
label: 'Manage',
action: () => navigate('/server-management/active-users/')
}
},
{
title: 'Pending invitations',
value: result.value?.admin.inviteList.totalCount?.toString() || 'N/A',
icon: EnvelopeIcon,
cta: {
type: 'button',
label: 'Manage',
action: () => navigate('/server-management/pending-invitations/')
}
}
])
const projectData = computed((): CardInfo[] => [
{
title: 'Projects',
value: result.value?.admin.projectList.totalCount?.toString() || 'N/A',
icon: HomeIcon,
cta: {
type: 'button',
label: 'Manage',
action: () => navigate('/server-management/projects/')
}
}
])
const openGithubReleasePage = () => {
window.open('https://github.com/specklesystems/speckle-server/releases', '_blank')
}
const navigate = async (path: string) => {
try {
await router.push(path)
} catch (error) {
logger.error('Failed to navigate:', error)
}
}
async function getLatestVersion(): Promise<string | null> {
try {
const response: Response = await fetch(
'https://api.github.com/repos/specklesystems/speckle-server/releases/latest'
)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
} else {
const data = (await response.json()) as GithubRelease
return data.tag_name
}
} catch (err) {
logger.error(err)
return null
}
}
latestVersion.value = await getLatestVersion()
</script>
@@ -1,186 +0,0 @@
<template>
<div>
<Portal to="navigation">
<HeaderNavLink to="/server-management" name="Server Management"></HeaderNavLink>
<HeaderNavLink to="/server-management/projects" name="Projects"></HeaderNavLink>
</Portal>
<div class="flex justify-between items-center mb-8">
<h1 class="h4 font-bold">Projects</h1>
<FormButton :icon-left="PlusIcon" @click="openNewProject = true">New</FormButton>
</div>
<FormTextInput
size="lg"
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
full-width
search
:show-clear="!!searchString"
placeholder="Search Projects"
class="rounded-md border border-outline-3"
@update:model-value="debounceSearchUpdate"
@change="($event) => searchUpdateHandler($event.value)"
/>
<LayoutTable
class="mt-8"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'type', header: 'Type', classes: 'col-span-1' },
{ id: 'created', header: 'Created', classes: 'col-span-2' },
{ id: 'modified', header: 'Modified', classes: 'col-span-2' },
{ id: 'models', header: 'Models', classes: 'col-span-1 text-right' },
{ id: 'versions', header: 'Versions', classes: 'col-span-1 text-right' },
{ id: 'contributors', header: 'Contributors', classes: 'col-span-2' }
]"
:items="projects"
:buttons="[{ icon: TrashIcon, label: 'Delete', action: openProjectDeleteDialog }]"
:on-row-click="handleProjectClick"
>
<template #name="{ item }">
{{ isProject(item) ? item.name : '' }}
</template>
<template #type="{ item }">
<span class="capitalize">
{{ isProject(item) ? item.visibility.toLowerCase() : '' }}
</span>
</template>
<template #created="{ item }">
{{ isProject(item) ? new Date(item.createdAt).toLocaleString('en-GB') : '' }}
</template>
<template #modified="{ item }">
{{ isProject(item) ? new Date(item.updatedAt).toLocaleString('en-GB') : '' }}
</template>
<template #models="{ item }">
{{ isProject(item) ? item.models.totalCount : '' }}
</template>
<template #versions="{ item }">
{{ isProject(item) ? item.versions.totalCount : '' }}
</template>
<template #contributors="{ item }">
<div v-if="isProject(item)" class="py-1">
<UserAvatarGroup :users="item.team.map((t) => t.user)" :max-count="3" />
</div>
</template>
</LayoutTable>
<CommonLoadingBar v-if="loading && !projects?.length" loading />
<InfiniteLoading
v-if="projects?.length"
:settings="{ identifier: infiniteLoaderId }"
class="py-4"
@infinite="infiniteLoad"
/>
<ServerManagementDeleteProjectDialog
v-model:open="showProjectDeleteDialog"
:project="projectToModify"
title="Delete project"
:result-variables="resultVariables"
/>
<ProjectsAddDialog v-model:open="openNewProject" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { debounce } from 'lodash-es'
import { useQuery } from '@vue/apollo-composable'
import { MagnifyingGlassIcon, TrashIcon, PlusIcon } from '@heroicons/vue/24/outline'
import { getProjectsQuery } from '~~/lib/server-management/graphql/queries'
import type { ItemType, ProjectItem } from '~~/lib/server-management/helpers/types'
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import { isProject } from '~~/lib/server-management/helpers/utils'
const logger = useLogger()
const router = useRouter()
useHead({
title: 'Projects'
})
definePageMeta({
middleware: ['admin']
})
const projectToModify = ref<ProjectItem | null>(null)
const searchString = ref('')
const showProjectDeleteDialog = ref(false)
const infiniteLoaderId = ref('')
const openNewProject = ref(false)
const {
result: extraPagesResult,
fetchMore: fetchMorePages,
variables: resultVariables,
onResult,
loading
} = useQuery(getProjectsQuery, () => ({
limit: 50,
query: searchString.value
}))
const moreToLoad = computed(
() =>
!extraPagesResult.value?.admin?.projectList ||
extraPagesResult.value.admin.projectList.items.length <
extraPagesResult.value.admin.projectList.totalCount
)
const projects = computed(() => extraPagesResult.value?.admin.projectList.items || [])
const openProjectDeleteDialog = (item: ItemType) => {
if (isProject(item)) {
projectToModify.value = item
showProjectDeleteDialog.value = true
}
}
const handleProjectClick = (item: ItemType) => {
router.push(`/projects/${item.id}`)
}
const infiniteLoad = async (state: InfiniteLoaderState) => {
const cursor = extraPagesResult.value?.admin?.projectList.cursor || null
if (!moreToLoad.value || !cursor) return state.complete()
try {
await fetchMorePages({
variables: {
cursor
}
})
} catch (e) {
logger.error(e)
state.error()
return
}
state.loaded()
if (!moreToLoad.value) {
state.complete()
}
}
const searchUpdateHandler = (value: string) => {
searchString.value = value
}
const debounceSearchUpdate = debounce(searchUpdateHandler, 500)
const calculateLoaderId = () => {
infiniteLoaderId.value = resultVariables.value?.query || ''
}
onResult(calculateLoaderId)
</script>
@@ -1,11 +1,11 @@
<template>
<div class="rounded-md" :class="[containerClasses, textClasses]">
<div class="flex" :class="subcontainerClasses">
<div class="flex-shrink-0">
<div v-if="!hideIcon" class="flex-shrink-0">
<Component :is="icon" :class="iconClasses" aria-hidden="true" />
</div>
<div :class="mainContentContainerClasses">
<h3 class="text-sm" :class="[hasDescription ? 'font-medium' : '']">
<h3 v-if="hasTitle" class="text-sm" :class="{ 'font-medium': hasDescription }">
<slot name="title">Title</slot>
</h3>
<div v-if="hasDescription" :class="descriptionWrapperClasses">
@@ -75,6 +75,7 @@ const props = withDefaults(
externalUrl?: boolean
}>
customIcon?: PropAnyComponent
hideIcon?: boolean
size?: Size
}>(),
{
@@ -85,6 +86,7 @@ const props = withDefaults(
const slots = useSlots()
const hasDescription = computed(() => !!slots['description'])
const hasTitle = computed(() => !!slots['title'])
const icon = computed(() => {
if (props.customIcon) return props.customIcon
@@ -117,16 +119,22 @@ const containerClasses = computed(() => {
switch (props.color) {
case 'success':
classParts.push('bg-success-lighter border-l-4 border-success')
classParts.push(
`bg-success-lighter ${!props.hideIcon && 'border-l-4 border-success'}`
)
break
case 'info':
classParts.push('bg-info-lighter border-l-4 border-info')
classParts.push(`bg-info-lighter ${!props.hideIcon && 'border-l-4 border-info'}`)
break
case 'danger':
classParts.push('bg-danger-lighter border-l-4 border-danger')
classParts.push(
`bg-danger-lighter ${!props.hideIcon && 'border-l-4 border-danger'}`
)
break
case 'warning':
classParts.push('bg-warning-lighter border-l-4 border-warning')
classParts.push(
`bg-warning-lighter ${!props.hideIcon && 'border-l-4 border-warning'}`
)
break
}
@@ -185,10 +193,14 @@ const descriptionWrapperClasses = computed(() => {
break
case 'default':
default:
classParts.push('mt-1 sm:mt-2 text-xs sm:text-sm')
classParts.push('text-xs sm:text-sm')
break
}
if (hasTitle.value && props.size !== 'xs') {
classParts.push('mt-1 sm:mt-2')
}
return classParts.join(' ')
})
@@ -6,42 +6,54 @@
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave="ease-in duration-400"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div
class="fixed top-0 left-0 w-full h-full bg-neutral-100/70 dark:bg-neutral-900/70 transition-opacity backdrop-blur-xs"
class="fixed top-0 left-0 w-full h-full bg-black/70 dark:bg-neutral-900/70 transition-opacity"
/>
</TransitionChild>
<div class="fixed top-0 left-0 z-10 h-screen !h-[100dvh] w-screen">
<div class="flex justify-center items-center h-full w-full p-4 sm:p-0">
<div
class="flex md:justify-center items-end md:items-center h-full w-full md:p-6"
>
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter="ease-out duration-5000"
enter-from="md:opacity-0 translate-y-[100%] md:translate-y-4"
enter-to="md:opacity-100 translate-y-0"
leave="ease-in duration-5000"
leave-from="md:opacity-100 translate-y-0"
leave-to="md:opacity-0 translate-y-[100%] md:translate-y-4"
@after-leave="$emit('fully-closed')"
>
<DialogPanel
:class="[
'transform rounded-lg text-foreground overflow-hidden bg-foundation text-left shadow-xl transition-all flex flex-col max-h-[90vh]',
'dialog-panel transform rounded-t-lg md:rounded-xl text-foreground overflow-hidden transition-all bg-foundation text-left shadow-xl flex flex-col md:h-auto',
fullscreen ? 'md:h-full' : 'md:max-h-[90vh]',
widthClasses
]"
:as="isForm ? 'form' : 'div'"
@submit.prevent="onFormSubmit"
>
<div :class="scrolledFromTop && 'relative z-20 shadow-lg'">
<div
v-if="hasTitle"
:class="scrolledFromTop && 'relative z-20 shadow-lg'"
>
<div
v-if="hasTitle"
class="flex items-center justify-start rounded-t-lg shrink-0 min-h-[2rem] sm:min-h-[4rem] p-4 sm:p-6 truncate text-lg sm:text-2xl font-bold"
class="flex items-center justify-start rounded-t-lg shrink-0 min-h-[2rem] sm:min-h-[4rem] p-6 truncate text-lg sm:text-2xl font-bold"
>
<div class="w-full truncate pr-12">
{{ title }}
<slot name="header"></slot>
<div class="flex items-center pr-12">
<ChevronLeftIcon
v-if="showBackButton"
class="w-5 h-5 -ml-1 mr-3"
@click="$emit('back')"
/>
<div class="w-full truncate">
{{ title }}
<slot name="header" />
</div>
</div>
</div>
</div>
@@ -56,23 +68,26 @@
<button
v-if="!hideCloser"
type="button"
class="absolute z-20 bg-foundation rounded-full p-1"
:class="hasTitle ? 'top-2 right-3 sm:top-4' : 'right-4 top-3'"
class="absolute z-20 bg-foundation hover:bg-foundation-page transition rounded-full p-1.5 shadow border top-5 right-5 border-outline-3"
@click="open = false"
>
<XMarkIcon class="h-5 sm:h-6 w-5 sm:w-6" />
<XMarkIcon class="h-4 w-4 md:w-5 md:h-5" />
</button>
<div
ref="slotContainer"
class="flex-1 simple-scrollbar overflow-y-auto text-sm sm:text-base"
:class="hasTitle ? 'px-4 pb-4 sm:px-6' : 'p-4 sm:p-6'"
:class="
hasTitle
? `px-6 pb-4 ${fullscreen && 'md:p-0'}`
: !fullscreen && 'p-6'
"
@scroll="onScroll"
>
<slot>Put your content here!</slot>
</div>
<div
v-if="hasButtons"
class="relative z-50 flex p-4 sm:p-6 gap-3 shrink-0 bg-foundation"
class="relative z-50 flex p-6 gap-3 shrink-0 bg-foundation"
:class="{
'shadow-t': !scrolledToBottom,
[buttonsWrapperClasses || '']: true
@@ -104,7 +119,7 @@
<script setup lang="ts">
import { Dialog, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { FormButton, type LayoutDialogButton } from '~~/src/lib'
import { XMarkIcon } from '@heroicons/vue/24/outline'
import { XMarkIcon, ChevronLeftIcon } from '@heroicons/vue/24/outline'
import { useResizeObserver, type ResizeObserverCallback } from '@vueuse/core'
import { computed, ref, useSlots, watch, onUnmounted } from 'vue'
import { throttle } from 'lodash'
@@ -115,12 +130,15 @@ type MaxWidthValue = 'sm' | 'md' | 'lg' | 'xl'
const emit = defineEmits<{
(e: 'update:open', v: boolean): void
(e: 'fully-closed'): void
(e: 'back'): void
}>()
const props = defineProps<{
open: boolean
maxWidth?: MaxWidthValue
fullscreen?: boolean
hideCloser?: boolean
showBackButton?: boolean
/**
* Prevent modal from closing when the user clicks outside of the modal or presses Esc
*/
@@ -154,7 +172,7 @@ useResizeObserver(
const isForm = computed(() => !!props.onSubmit)
const hasButtons = computed(() => props.buttons || slots.buttons)
const hasTitle = computed(() => props.title || slots.header)
const hasTitle = computed(() => !!props.title || !!slots.header)
const open = computed({
get: () => props.open,
@@ -177,19 +195,20 @@ const maxWidthWeight = computed(() => {
})
const widthClasses = computed(() => {
const classParts: string[] = ['w-full', 'sm:w-full sm:max-w-2xl']
const classParts: string[] = ['w-full', 'sm:w-full']
if (maxWidthWeight.value >= 1) {
if (!props.fullscreen) {
classParts.push('md:max-w-2xl')
}
if (maxWidthWeight.value >= 2) {
classParts.push('lg:max-w-4xl')
}
if (maxWidthWeight.value >= 3) {
classParts.push('xl:max-w-6xl')
}
if (maxWidthWeight.value >= 4) {
classParts.push('2xl:max-w-7xl')
if (maxWidthWeight.value >= 2) {
classParts.push('lg:max-w-4xl')
}
if (maxWidthWeight.value >= 3) {
classParts.push('xl:max-w-6xl')
}
if (maxWidthWeight.value >= 4) {
classParts.push('2xl:max-w-7xl')
}
}
return classParts.join(' ')
@@ -241,4 +260,9 @@ html.dialog-open {
html.dialog-open body {
overflow: hidden !important;
}
/* Workaround because in Tailwind vh gets added after dvh */
.dialog-panel {
height: 98vh;
height: 98dvh;
}
</style>
@@ -13,7 +13,6 @@
v-for="(column, colIndex) in columns"
:key="column.id"
:class="getHeaderClasses(column.id, colIndex)"
class="capitalize"
>
{{ column.header }}
</div>
@@ -11,7 +11,7 @@
{{ title }}
</h6>
</button>
<div v-else class="flex gap-1 items-center w-full p-0.5 text-foreground-2">
<div v-else class="flex gap-1 items-center w-full p-1 text-foreground-2">
<div
v-if="$slots['title-icon']"
class="h-5 w-5 flex items-center justify-center"
@@ -1,52 +1,51 @@
<template>
<div>
<NuxtLink
v-if="!hasChildren"
:to="to"
class="group flex items-center justify-between gap-2 shrink-0 text-sm select-none rounded-md w-full hover:bg-primary-muted p-1.5 cursor-pointer"
active-class="bg-foundation-focus hover:!bg-foundation-focus"
:external="external"
:target="external ? '_blank' : undefined"
<component
:is="linkComponent"
v-if="!hasChildren"
:to="to"
class="group flex items-center justify-between gap-2 shrink-0 text-sm select-none rounded-md w-full hover:bg-primary-muted py-1.5 px-5 cursor-pointer"
exact-active-class="bg-foundation-focus hover:!bg-foundation-focus"
:external="external"
:target="external ? '_blank' : undefined"
>
<div class="flex items-center gap-2">
<div v-if="$slots.icon" class="h-5 w-5 flex items-center justify-center">
<slot name="icon" />
</div>
<span :class="$slots.icon ? '' : 'pl-2'">
{{ label }}
</span>
</div>
<div
v-if="tag"
class="text-xs uppercase bg-primary-muted py-0.5 px-2 rounded-full font-medium text-primary-focus group-hover:bg-white"
>
<div class="flex items-center gap-2">
<div v-if="$slots.icon" class="h-5 w-5 flex items-center justify-center">
<slot name="icon" />
</div>
<span :class="$slots.icon ? '' : 'pl-2'">
{{ label }}
</span>
</div>
<div
v-if="tag"
class="text-xs uppercase bg-primary-muted py-0.5 px-2 rounded-full font-medium text-primary-focus group-hover:bg-white"
>
{{ tag }}
</div>
</NuxtLink>
<div v-else class="flex flex-col">
<button
class="group flex gap-1.5 items-center w-full hover:bg-foundation-3 rounded-md p-0.5 cursor-pointer"
@click="isOpen = !isOpen"
>
<ChevronDownIcon :class="isOpen ? '' : 'rotate-180'" class="h-2.5 w-2.5" />
<h6 class="font-bold text-foreground-2 text-xs flex items-center gap-1.5">
{{ label }}
</h6>
</button>
<div v-show="isOpen" class="pl-4">
<slot></slot>
</div>
{{ tag }}
</div>
</component>
<div v-else class="flex flex-col">
<button
class="group flex gap-1.5 items-center w-full hover:bg-foundation-3 rounded-md p-0.5 cursor-pointer"
@click="isOpen = !isOpen"
>
<ChevronDownIcon :class="isOpen ? '' : 'rotate-180'" class="h-2.5 w-2.5" />
<h6 class="font-bold text-foreground-2 text-xs flex items-center gap-1.5">
{{ label }}
</h6>
</button>
<div v-show="isOpen" class="pl-4">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { ref, resolveDynamicComponent, useSlots } from 'vue'
import { ref, computed, resolveDynamicComponent, useSlots } from 'vue'
defineProps<{
const props = defineProps<{
label: string
to: string
to?: string
tag?: string
external?: boolean
}>()
@@ -55,6 +54,8 @@ const isOpen = ref(true)
const NuxtLink = resolveDynamicComponent('NuxtLink')
const linkComponent = computed(() => (props.to ? NuxtLink : 'a'))
const slots = useSlots()
const hasChildren = !!slots.default
@@ -19,7 +19,7 @@ export function markClassesUsed(classes: string[]) {
*/
export enum TailwindBreakpoints {
sm = 640,
md = 746,
md = 768,
lg = 1024,
xl = 1280,
'2xl' = 1536