feat(fe2): app authorization workflow redesign [WBX-217] (#2044)

* WIP

* new permissions table

* permissions grouped

* updated scope descriptions

* more scope copy adjustments

* allow auth error handling

* manually closable toast notification

* fixed mentions rendering

* error view

* not you? feature

* cleanup

* minor styling changes

* WIP table

* finished authorized apps table

* minor cleanup

* cleaning up comment

* testing changes
This commit is contained in:
Kristaps Fabians Geikins
2024-02-23 16:50:07 +02:00
committed by GitHub
parent c4ce83ed2a
commit 6af6c656a4
47 changed files with 795 additions and 455 deletions
@@ -24,3 +24,11 @@ body,
div#__nuxt {
height: 100%;
}
@layer components {
.terms-of-service {
a {
@apply underline;
}
}
}
@@ -61,7 +61,7 @@
<FormButton submit full-width class="mt-4" :disabled="loading">Sign up</FormButton>
<div
v-if="serverInfo.termsOfService"
class="mt-2 text-xs text-foreground-2 text-center linkify-tos"
class="mt-2 text-xs text-foreground-2 text-center terms-of-service"
v-html="serverInfo.termsOfService"
></div>
<div class="mt-2 sm:mt-8 text-center text-xs sm:text-base">
@@ -164,8 +164,3 @@ watch(
{ immediate: true }
)
</script>
<style>
.linkify-tos a {
text-decoration: underline;
}
</style>
@@ -3,7 +3,7 @@
<a
:class="[
'flex flex-col p-2 cursor-pointer',
isSelected ? 'bg-foundation-2' : 'hover:bg-foundation-3'
isSelected ? 'bg-foundation-focus dark:bg-foundation-2' : 'hover:bg-foundation-3'
]"
@click="($event) => $emit('click', $event)"
>
@@ -24,8 +24,9 @@ import type {
TiptapEditorSchemaOptions
} from '~~/lib/common/helpers/tiptap'
import type { Nullable } from '@speckle/shared'
import { userProfileRoute } from '~~/lib/common/helpers/route'
// import { userProfileRoute } from '~~/lib/common/helpers/route'
import { onKeyDown } from '@vueuse/core'
import { noop } from 'lodash-es'
const emit = defineEmits<{
(e: 'update:modelValue', val: JSONContent): void
@@ -95,17 +96,19 @@ const onEditorContentClick = (e: MouseEvent) => {
e.stopPropagation()
}
const onMentionClick = (userId: string, e: MouseEvent) => {
if (!props.readonly) return
// TODO: No profile page to link to in FE2 yet
// const onMentionClick = (userId: string, e: MouseEvent) => {
// if (!props.readonly) return
const path = userProfileRoute(userId)
const isMetaKey = e.metaKey || e.ctrlKey
if (isMetaKey) {
window.open(path, '_blank')
} else {
window.location.href = path
}
}
// const path = userProfileRoute(userId)
// const isMetaKey = e.metaKey || e.ctrlKey
// if (isMetaKey) {
// window.open(path, '_blank')
// } else {
// window.location.href = path
// }
// }
const onMentionClick = noop
onKeyDown(
'Escape',
@@ -184,7 +187,7 @@ onBeforeUnmount(() => {
box-shadow: unset !important;
.editor-mention {
cursor: pointer;
/* cursor: pointer; TODO: Reenable once mentions are clickable again */
}
}
}
@@ -1,18 +1,21 @@
<template>
<LayoutDialog v-model:open="isOpen" max-width="sm" :buttons="dialogButtons">
<template #header>Delete {{ itemType }}</template>
<template #header>{{ title }}</template>
<div class="flex flex-col gap-6 text-sm text-foreground">
<p>
Are you sure you want to
<strong>permanently delete</strong>
<strong>permanently {{ lowerFirst(itemActionVerb) }}</strong>
the selected {{ itemType.toLowerCase() }}?
<template v-if="isAuthorization(item)">
(Removing access to an app will log you out of it on all devices.)
</template>
</p>
<div v-if="item" class="flex flex-col gap-2">
<strong class="truncate">{{ item.name }}</strong>
</div>
<p>
This
This action
<strong>cannot</strong>
be undone.
</p>
@@ -21,49 +24,75 @@
</template>
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable'
import { useMutation, useMutationLoading } from '@vue/apollo-composable'
import { LayoutDialog } from '@speckle/ui-components'
import type {
ApplicationItem,
TokenItem
TokenItem,
AuthorizedAppItem
} from '~~/lib/developer-settings/helpers/types'
import {
deleteAccessTokenMutation,
deleteApplicationMutation
deleteApplicationMutation,
revokeAppAccessMutation
} from '~~/lib/developer-settings/graphql/mutations'
import {
convertThrowIntoFetchResult,
getCacheId,
getFirstErrorMessage
getFirstErrorMessage,
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import { useGlobalToast, ToastNotificationType } from '~~/lib/common/composables/toast'
import { lowerFirst } from 'lodash-es'
import { useActiveUser } from '~/lib/auth/composables/activeUser'
import type { User } from '~/lib/common/generated/gql/graphql'
type ItemType = TokenItem | ApplicationItem | AuthorizedAppItem | null
const isToken = (i: ItemType): i is TokenItem => !!(i && 'lastChars' in i)
const isApplication = (i: ItemType): i is ApplicationItem => !!(i && 'secret' in i)
const isAuthorization = (i: ItemType): i is AuthorizedAppItem =>
!(isToken(i) || isApplication(i))
const props = defineProps<{
item: TokenItem | ApplicationItem | null
item: ItemType
}>()
const { triggerNotification } = useGlobalToast()
const { mutate: deleteTokenMutation } = useMutation(deleteAccessTokenMutation)
const { mutate: deleteAppMutation } = useMutation(deleteApplicationMutation)
const isLoading = useMutationLoading()
const { mutate: deleteToken } = useMutation(deleteAccessTokenMutation)
const { mutate: deleteApp } = useMutation(deleteApplicationMutation)
const { mutate: revokeAuthorization } = useMutation(revokeAppAccessMutation)
const { userId } = useActiveUser()
const isOpen = defineModel<boolean>('open', { required: true })
const itemType = computed(() => {
return props.item && 'secret' in props.item ? 'Application' : 'Access Token'
if (isToken(props.item)) {
return `Access Token`
} else if (isApplication(props.item)) {
return `Application`
} else {
return 'Authorization'
}
})
const isApplication = (i: TokenItem | ApplicationItem | null): i is ApplicationItem =>
!!(i && 'secret' in i)
const itemActionVerb = computed(() => {
return isToken(props.item) || isApplication(props.item) ? 'Delete' : 'Remove'
})
const title = computed(() => {
return `${itemActionVerb.value} ${itemType.value}`
})
const deleteConfirmed = async () => {
const uid = userId.value
const itemId = props.item?.id
if (!itemId) {
if (!itemId || !uid) {
return
}
if (!isApplication(props.item)) {
const result = await deleteTokenMutation(
if (isToken(props.item)) {
const result = await deleteToken(
{
token: itemId
},
@@ -92,8 +121,8 @@ const deleteConfirmed = async () => {
description: errorMessage
})
}
} else {
const result = await deleteAppMutation(
} else if (isApplication(props.item)) {
const result = await deleteApp(
{
appId: itemId
},
@@ -122,19 +151,57 @@ const deleteConfirmed = async () => {
description: errorMessage
})
}
} else {
const result = await revokeAuthorization(
{ appId: itemId },
{
update: (cache, res) => {
if (res.data?.appRevokeAccess) {
modifyObjectFields<undefined, User['authorizedApps']>(
cache,
getCacheId('User', uid),
(_fieldName, _variables, value) => {
if (!value) return value
return value.filter(
(a) => a.__ref !== getCacheId('ServerAppListItem', itemId)
)
},
{ fieldNameWhitelist: ['authorizedApps'] }
)
}
}
}
).catch(convertThrowIntoFetchResult)
if (result?.data?.appRevokeAccess) {
isOpen.value = false
triggerNotification({
type: ToastNotificationType.Success,
title: 'Authorization removed',
description: 'The application authorization has been successfully removed'
})
} else {
const errorMessage = getFirstErrorMessage(result?.errors)
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Failed to revoke app authorization',
description: errorMessage
})
}
}
}
const dialogButtons = [
{
text: 'Delete',
props: { color: 'danger', fullWidth: true },
onClick: deleteConfirmed
},
const dialogButtons = computed(() => [
{
text: 'Cancel',
props: { color: 'secondary', fullWidth: true, outline: true },
onClick: () => (isOpen.value = false)
onClick: (): boolean => (isOpen.value = false)
},
{
text: itemActionVerb.value,
props: { color: 'danger', fullWidth: true },
disabled: isLoading.value,
onClick: deleteConfirmed
}
]
])
</script>
@@ -1,7 +1,8 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row gap-3 md:gap-0 justify-between">
<h1 class="text-2xl font-bold">{{ title }}</h1>
<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">
<FormButton
v-for="(button, index) in buttons"
@@ -30,6 +31,7 @@ withDefaults(
title: string
text?: string
buttons?: Button[]
subheading?: boolean
}>(),
{
buttons: () => []
@@ -1,10 +1,12 @@
<template>
<NuxtLink class="flex items-center shrink-0" :to="to" :target="target">
<Component
:is="mainComponent"
class="flex items-center shrink-0"
:to="to"
:target="target"
>
<img
class="h-8 w-8 block"
:class="{
grayscale: active
}"
src="~~/assets/images/speckle_logo_big.png"
alt="Speckle"
/>
@@ -16,20 +18,22 @@
>
Speckle
</div>
</NuxtLink>
</Component>
</template>
<script setup lang="ts">
withDefaults(
const props = withDefaults(
defineProps<{
minimal?: boolean
active?: boolean
to?: string
showTextOnMobile?: boolean
target?: string
noLink?: boolean
}>(),
{
active: true,
to: '/'
}
)
const NuxtLink = resolveComponent('NuxtLink')
const mainComponent = computed(() => (props.noLink ? 'div' : NuxtLink))
</script>
@@ -61,7 +61,7 @@
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="onThemeClick"
@click="toggleTheme"
>
<Icon class="w-5 h-5" />
{{ isDarkTheme ? 'Light Mode' : 'Dark Mode' }}
@@ -145,7 +145,7 @@ import {
import { Roles } from '@speckle/shared'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useTheme, AppTheme } from '~~/lib/core/composables/theme'
import { useTheme } from '~~/lib/core/composables/theme'
import { useServerInfo } from '~/lib/core/composables/server'
import type { RouteLocationRaw } from 'vue-router'
import { homeRoute, profileRoute } from '~/lib/common/helpers/route'
@@ -156,7 +156,7 @@ defineProps<{
const { logout } = useAuthManager()
const { activeUser, isGuest } = useActiveUser()
const { isDarkTheme, setTheme } = useTheme()
const { isDarkTheme, toggleTheme } = useTheme()
const { serverInfo } = useServerInfo()
const router = useRouter()
const route = useRoute()
@@ -173,14 +173,6 @@ const toggleInviteDialog = () => {
showInviteDialog.value = true
}
const onThemeClick = () => {
if (isDarkTheme.value) {
setTheme(AppTheme.Light)
} else {
setTheme(AppTheme.Dark)
}
}
const goToConnectors = () => {
router.push('/downloads')
}
@@ -2,7 +2,7 @@
<button
type="button"
class="rounded-full h-8 w-8 bg-foreground text-foundation hover:text-primary flex justify-center items-center ring-offset-foundation focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 transition"
@click="onClick"
@click="toggleTheme"
>
<span class="sr-only">Toggle dark mode</span>
<Icon class="h-4 w-4" aria-hidden="true" />
@@ -10,16 +10,8 @@
</template>
<script setup lang="ts">
import { SunIcon, MoonIcon } from '@heroicons/vue/24/solid'
import { useTheme, AppTheme } from '~~/lib/core/composables/theme'
import { useTheme } from '~~/lib/core/composables/theme'
const { isDarkTheme, setTheme } = useTheme()
const { isDarkTheme, toggleTheme } = useTheme()
const Icon = computed(() => (isDarkTheme.value ? SunIcon : MoonIcon))
const onClick = () => {
if (isDarkTheme.value) {
setTheme(AppTheme.Light)
} else {
setTheme(AppTheme.Dark)
}
}
</script>
@@ -0,0 +1,4 @@
import { useActiveUser } from '~/lib/auth/composables/activeUser'
import { useGlobalToast } from '~/lib/common/composables/toast'
export { useGlobalToast, useActiveUser }
@@ -1,28 +1,19 @@
<template>
<main
class="h-[100dvh] w-screen flex items-center justify-center overflow-y-auto overflow-x-hidden"
>
<main class="flex items-center justify-center">
<div class="absolute inset-0 pointer-events-none p-4 text-right">
<FormButton size="xs" text class="pointer-events-auto" @click="onThemeClick">
<FormButton size="xs" text class="pointer-events-auto" @click="toggleTheme">
<Icon class="w-4 h-4" />
</FormButton>
</div>
<div class="relative mt-4 mx-2">
<div class="relative mt-24 mx-2 mb-8">
<slot />
</div>
</main>
</template>
<script setup lang="ts">
import { SunIcon, MoonIcon } from '@heroicons/vue/24/solid'
import { useTheme, AppTheme } from '~~/lib/core/composables/theme'
import { useTheme } from '~~/lib/core/composables/theme'
const { isDarkTheme, setTheme } = useTheme()
const { isDarkTheme, toggleTheme } = useTheme()
const Icon = computed(() => (isDarkTheme.value ? SunIcon : MoonIcon))
const onThemeClick = () => {
if (isDarkTheme.value) {
setTheme(AppTheme.Light)
} else {
setTheme(AppTheme.Dark)
}
}
</script>
@@ -57,11 +57,21 @@ export function useActiveUser() {
const user = activeUser.value
return getDistinctId(user)
})
const userId = computed(() => activeUser.value?.id)
const isGuest = computed(() => activeUser.value?.role === Roles.Server.Guest)
const isAdmin = computed(() => activeUser.value?.role === Roles.Server.Admin)
return { activeUser, isLoggedIn, distinctId, refetch, onResult, isGuest, isAdmin }
return {
activeUser,
userId,
isLoggedIn,
distinctId,
refetch,
onResult,
isGuest,
isAdmin
}
}
/**
@@ -1,3 +1,4 @@
import type { RouteLocationNormalized } from '#vue-router'
import type { Optional } from '@speckle/shared'
import { reduce } from 'lodash-es'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
@@ -8,10 +9,12 @@ const usePostAuthRedirectCookie = () =>
maxAge: 60 * 5 // 5 mins
})
export const usePostAuthRedirect = () => {
export const usePostAuthRedirect = (
options?: Partial<{ route: RouteLocationNormalized }>
) => {
const cookie = usePostAuthRedirectCookie()
const router = useRouter()
const route = useRoute()
const route = options?.route || useRoute()
const logger = useLogger()
const hadPendingRedirect = computed(() => !!cookie.value?.length)
@@ -38,7 +38,7 @@ export function useGlobalToastManager() {
// (re-)init timeout
stop()
start()
if (newVal.autoClose !== false) start()
})
},
{ deep: true }
@@ -86,8 +86,10 @@ const documents = {
"\n mutation DeleteApplication($appId: String!) {\n appDelete(appId: $appId)\n }\n": types.DeleteApplicationDocument,
"\n mutation CreateApplication($app: AppCreateInput!) {\n appCreate(app: $app)\n }\n": types.CreateApplicationDocument,
"\n mutation EditApplication($app: AppUpdateInput!) {\n appUpdate(app: $app)\n }\n": types.EditApplicationDocument,
"\n mutation RevokeAppAccess($appId: String!) {\n appRevokeAccess(appId: $appId)\n }\n": types.RevokeAppAccessDocument,
"\n query DeveloperSettingsAccessTokens {\n activeUser {\n id\n apiTokens {\n id\n name\n lastUsed\n lastChars\n createdAt\n scopes\n }\n }\n }\n": types.DeveloperSettingsAccessTokensDocument,
"\n query DeveloperSettingsApplications {\n activeUser {\n createdApps {\n id\n secret\n name\n description\n redirectUrl\n scopes {\n name\n description\n }\n }\n id\n }\n }\n": types.DeveloperSettingsApplicationsDocument,
"\n query DeveloperSettingsAuthorizedApps {\n activeUser {\n id\n authorizedApps {\n id\n description\n name\n author {\n id\n name\n avatar\n }\n }\n }\n }\n": types.DeveloperSettingsAuthorizedAppsDocument,
"\n query SearchProjects($search: String, $onlyWithRoles: [String!] = null) {\n activeUser {\n projects(limit: 10, filter: { search: $search, onlyWithRoles: $onlyWithRoles }) {\n totalCount\n items {\n ...FormSelectProjects_Project\n }\n }\n }\n }\n": types.SearchProjectsDocument,
"\n fragment ProjectDashboardItemNoModels on Project {\n id\n name\n createdAt\n updatedAt\n role\n team {\n user {\n id\n name\n avatar\n }\n }\n ...ProjectPageModelsCardProject\n }\n": types.ProjectDashboardItemNoModelsFragmentDoc,
"\n fragment ProjectDashboardItem on Project {\n id\n ...ProjectDashboardItemNoModels\n models(limit: 4, filter: { onlyWithVersions: true }) {\n totalCount\n items {\n ...ProjectPageLatestItemsModelItem\n }\n }\n pendingImportedModels(limit: 4) {\n ...PendingFileUpload\n }\n }\n": types.ProjectDashboardItemFragmentDoc,
@@ -481,6 +483,10 @@ export function graphql(source: "\n mutation CreateApplication($app: AppCreateI
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation EditApplication($app: AppUpdateInput!) {\n appUpdate(app: $app)\n }\n"): (typeof documents)["\n mutation EditApplication($app: AppUpdateInput!) {\n appUpdate(app: $app)\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 mutation RevokeAppAccess($appId: String!) {\n appRevokeAccess(appId: $appId)\n }\n"): (typeof documents)["\n mutation RevokeAppAccess($appId: String!) {\n appRevokeAccess(appId: $appId)\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -489,6 +495,10 @@ export function graphql(source: "\n query DeveloperSettingsAccessTokens {\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 query DeveloperSettingsApplications {\n activeUser {\n createdApps {\n id\n secret\n name\n description\n redirectUrl\n scopes {\n name\n description\n }\n }\n id\n }\n }\n"): (typeof documents)["\n query DeveloperSettingsApplications {\n activeUser {\n createdApps {\n id\n secret\n name\n description\n redirectUrl\n scopes {\n name\n description\n }\n }\n id\n }\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 query DeveloperSettingsAuthorizedApps {\n activeUser {\n id\n authorizedApps {\n id\n description\n name\n author {\n id\n name\n avatar\n }\n }\n }\n }\n"): (typeof documents)["\n query DeveloperSettingsAuthorizedApps {\n activeUser {\n id\n authorizedApps {\n id\n description\n name\n author {\n id\n name\n avatar\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2603,7 +2603,7 @@ export type User = {
/** Returns a list of your personal api tokens. */
apiTokens: Array<ApiToken>;
/** Returns the apps you have authorized. */
authorizedApps?: Maybe<Array<Maybe<ServerAppListItem>>>;
authorizedApps?: Maybe<Array<ServerAppListItem>>;
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
/**
@@ -3171,6 +3171,13 @@ export type EditApplicationMutationVariables = Exact<{
export type EditApplicationMutation = { __typename?: 'Mutation', appUpdate: boolean };
export type RevokeAppAccessMutationVariables = Exact<{
appId: Scalars['String'];
}>;
export type RevokeAppAccessMutation = { __typename?: 'Mutation', appRevokeAccess?: boolean | null };
export type DeveloperSettingsAccessTokensQueryVariables = Exact<{ [key: string]: never; }>;
@@ -3181,6 +3188,11 @@ export type DeveloperSettingsApplicationsQueryVariables = Exact<{ [key: string]:
export type DeveloperSettingsApplicationsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, createdApps?: Array<{ __typename?: 'ServerApp', id: string, secret?: string | null, name: string, description?: string | null, redirectUrl: string, scopes: Array<{ __typename?: 'Scope', name: string, description: string }> }> | null } | null };
export type DeveloperSettingsAuthorizedAppsQueryVariables = Exact<{ [key: string]: never; }>;
export type DeveloperSettingsAuthorizedAppsQuery = { __typename?: 'Query', activeUser?: { __typename?: 'User', id: string, authorizedApps?: Array<{ __typename?: 'ServerAppListItem', id: string, description?: string | null, name: string, author?: { __typename?: 'AppAuthor', id: string, name: string, avatar?: string | null } | null }> | null } | null };
export type SearchProjectsQueryVariables = Exact<{
search?: InputMaybe<Scalars['String']>;
onlyWithRoles?: InputMaybe<Array<Scalars['String']> | Scalars['String']>;
@@ -3838,8 +3850,10 @@ export const CreateAccessTokenDocument = {"kind":"Document","definitions":[{"kin
export const DeleteApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appDelete"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationMutation, DeleteApplicationMutationVariables>;
export const CreateApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"app"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AppCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"app"},"value":{"kind":"Variable","name":{"kind":"Name","value":"app"}}}]}]}}]} as unknown as DocumentNode<CreateApplicationMutation, CreateApplicationMutationVariables>;
export const EditApplicationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"EditApplication"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"app"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AppUpdateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appUpdate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"app"},"value":{"kind":"Variable","name":{"kind":"Name","value":"app"}}}]}]}}]} as unknown as DocumentNode<EditApplicationMutation, EditApplicationMutationVariables>;
export const RevokeAppAccessDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RevokeAppAccess"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"appRevokeAccess"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]}]}}]} as unknown as DocumentNode<RevokeAppAccessMutation, RevokeAppAccessMutationVariables>;
export const DeveloperSettingsAccessTokensDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DeveloperSettingsAccessTokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"apiTokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUsed"}},{"kind":"Field","name":{"kind":"Name","value":"lastChars"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}}]}}]}}]}}]} as unknown as DocumentNode<DeveloperSettingsAccessTokensQuery, DeveloperSettingsAccessTokensQueryVariables>;
export const DeveloperSettingsApplicationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DeveloperSettingsApplications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createdApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"redirectUrl"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode<DeveloperSettingsApplicationsQuery, DeveloperSettingsApplicationsQueryVariables>;
export const DeveloperSettingsAuthorizedAppsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"DeveloperSettingsAuthorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"authorizedApps"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatar"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeveloperSettingsAuthorizedAppsQuery, DeveloperSettingsAuthorizedAppsQueryVariables>;
export const SearchProjectsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchProjects"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},"defaultValue":{"kind":"NullValue"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activeUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"onlyWithRoles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"onlyWithRoles"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FormSelectProjects_Project"}}]}}]}}]}}]}},...FormSelectProjects_ProjectFragmentDoc.definitions]} as unknown as DocumentNode<SearchProjectsQuery, SearchProjectsQueryVariables>;
export const CreateModelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateModel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateModelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"modelMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageLatestItemsModelItem"}}]}}]}}]}},...ProjectPageLatestItemsModelItemFragmentDoc.definitions,...PendingFileUploadFragmentDoc.definitions,...ProjectPageModelsCardRenameDialogFragmentDoc.definitions,...ProjectPageModelsCardDeleteDialogFragmentDoc.definitions,...ProjectPageModelsActionsFragmentDoc.definitions,...ModelCardAutomationStatus_ModelFragmentDoc.definitions,...ModelCardAutomationStatus_AutomationsStatusFragmentDoc.definitions]} as unknown as DocumentNode<CreateModelMutation, CreateModelMutationVariables>;
export const CreateProjectDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateProject"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ProjectCreateInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectPageProject"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ProjectDashboardItem"}}]}}]}}]}},...ProjectPageProjectFragmentDoc.definitions,...ProjectPageProjectHeaderFragmentDoc.definitions,...ProjectPageStatsBlockTeamFragmentDoc.definitions,...LimitedUserAvatarFragmentDoc.definitions,...ProjectPageTeamDialogFragmentDoc.definitions,...ProjectsPageTeamDialogManagePermissions_ProjectFragmentDoc.definitions,...ProjectPageStatsBlockVersionsFragmentDoc.definitions,...ProjectPageStatsBlockModelsFragmentDoc.definitions,...ProjectPageStatsBlockCommentsFragmentDoc.definitions,...ProjectPageLatestItemsModelsFragmentDoc.definitions,...ProjectPageModelsStructureItem_ProjectFragmentDoc.definitions,...ProjectPageModelsActions_ProjectFragmentDoc.definitions,...ProjectsModelPageEmbed_ProjectFragmentDoc.definitions,...ProjectPageLatestItemsCommentsFragmentDoc.definitions,...ProjectDashboardItemFragmentDoc.definitions,...ProjectDashboardItemNoModelsFragmentDoc.definitions,...ProjectPageModelsCardProjectFragmentDoc.definitions,...ProjectPageLatestItemsModelItemFragmentDoc.definitions,...PendingFileUploadFragmentDoc.definitions,...ProjectPageModelsCardRenameDialogFragmentDoc.definitions,...ProjectPageModelsCardDeleteDialogFragmentDoc.definitions,...ProjectPageModelsActionsFragmentDoc.definitions,...ModelCardAutomationStatus_ModelFragmentDoc.definitions,...ModelCardAutomationStatus_AutomationsStatusFragmentDoc.definitions]} as unknown as DocumentNode<CreateProjectMutation, CreateProjectMutationVariables>;
@@ -1,179 +0,0 @@
/**
* Lightweight MD5 implementation.
* @see http://www.myersdaily.org/joseph/javascript/md5-text.html
*/
function md5cycle(x, k) {
let a = x[0],
b = x[1],
c = x[2],
d = x[3]
a = ff(a, b, c, d, k[0], 7, -680876936)
d = ff(d, a, b, c, k[1], 12, -389564586)
c = ff(c, d, a, b, k[2], 17, 606105819)
b = ff(b, c, d, a, k[3], 22, -1044525330)
a = ff(a, b, c, d, k[4], 7, -176418897)
d = ff(d, a, b, c, k[5], 12, 1200080426)
c = ff(c, d, a, b, k[6], 17, -1473231341)
b = ff(b, c, d, a, k[7], 22, -45705983)
a = ff(a, b, c, d, k[8], 7, 1770035416)
d = ff(d, a, b, c, k[9], 12, -1958414417)
c = ff(c, d, a, b, k[10], 17, -42063)
b = ff(b, c, d, a, k[11], 22, -1990404162)
a = ff(a, b, c, d, k[12], 7, 1804603682)
d = ff(d, a, b, c, k[13], 12, -40341101)
c = ff(c, d, a, b, k[14], 17, -1502002290)
b = ff(b, c, d, a, k[15], 22, 1236535329)
a = gg(a, b, c, d, k[1], 5, -165796510)
d = gg(d, a, b, c, k[6], 9, -1069501632)
c = gg(c, d, a, b, k[11], 14, 643717713)
b = gg(b, c, d, a, k[0], 20, -373897302)
a = gg(a, b, c, d, k[5], 5, -701558691)
d = gg(d, a, b, c, k[10], 9, 38016083)
c = gg(c, d, a, b, k[15], 14, -660478335)
b = gg(b, c, d, a, k[4], 20, -405537848)
a = gg(a, b, c, d, k[9], 5, 568446438)
d = gg(d, a, b, c, k[14], 9, -1019803690)
c = gg(c, d, a, b, k[3], 14, -187363961)
b = gg(b, c, d, a, k[8], 20, 1163531501)
a = gg(a, b, c, d, k[13], 5, -1444681467)
d = gg(d, a, b, c, k[2], 9, -51403784)
c = gg(c, d, a, b, k[7], 14, 1735328473)
b = gg(b, c, d, a, k[12], 20, -1926607734)
a = hh(a, b, c, d, k[5], 4, -378558)
d = hh(d, a, b, c, k[8], 11, -2022574463)
c = hh(c, d, a, b, k[11], 16, 1839030562)
b = hh(b, c, d, a, k[14], 23, -35309556)
a = hh(a, b, c, d, k[1], 4, -1530992060)
d = hh(d, a, b, c, k[4], 11, 1272893353)
c = hh(c, d, a, b, k[7], 16, -155497632)
b = hh(b, c, d, a, k[10], 23, -1094730640)
a = hh(a, b, c, d, k[13], 4, 681279174)
d = hh(d, a, b, c, k[0], 11, -358537222)
c = hh(c, d, a, b, k[3], 16, -722521979)
b = hh(b, c, d, a, k[6], 23, 76029189)
a = hh(a, b, c, d, k[9], 4, -640364487)
d = hh(d, a, b, c, k[12], 11, -421815835)
c = hh(c, d, a, b, k[15], 16, 530742520)
b = hh(b, c, d, a, k[2], 23, -995338651)
a = ii(a, b, c, d, k[0], 6, -198630844)
d = ii(d, a, b, c, k[7], 10, 1126891415)
c = ii(c, d, a, b, k[14], 15, -1416354905)
b = ii(b, c, d, a, k[5], 21, -57434055)
a = ii(a, b, c, d, k[12], 6, 1700485571)
d = ii(d, a, b, c, k[3], 10, -1894986606)
c = ii(c, d, a, b, k[10], 15, -1051523)
b = ii(b, c, d, a, k[1], 21, -2054922799)
a = ii(a, b, c, d, k[8], 6, 1873313359)
d = ii(d, a, b, c, k[15], 10, -30611744)
c = ii(c, d, a, b, k[6], 15, -1560198380)
b = ii(b, c, d, a, k[13], 21, 1309151649)
a = ii(a, b, c, d, k[4], 6, -145523070)
d = ii(d, a, b, c, k[11], 10, -1120210379)
c = ii(c, d, a, b, k[2], 15, 718787259)
b = ii(b, c, d, a, k[9], 21, -343485551)
x[0] = add32(a, x[0])
x[1] = add32(b, x[1])
x[2] = add32(c, x[2])
x[3] = add32(d, x[3])
}
function cmn(q, a, b, x, s, t) {
a = add32(add32(a, q), add32(x, t))
return add32((a << s) | (a >>> (32 - s)), b)
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | (~b & d), a, b, x, s, t)
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t)
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | ~d), a, b, x, s, t)
}
function md51(s) {
const n = s.length,
state = [1732584193, -271733879, -1732584194, 271733878]
let i
for (i = 64; i <= s.length; i += 64) {
md5cycle(state, md5blk(s.substring(i - 64, i)))
}
s = s.substring(i - 64)
const tail = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << (i % 4 << 3)
tail[i >> 2] |= 0x80 << (i % 4 << 3)
if (i > 55) {
md5cycle(state, tail)
for (i = 0; i < 16; i++) tail[i] = 0
}
tail[14] = n * 8
md5cycle(state, tail)
return state
}
function md5blk(s) {
/* I figured global was faster. */
const md5blks = []
let i
for (i = 0; i < 64; i += 4) {
md5blks[i >> 2] =
s.charCodeAt(i) +
(s.charCodeAt(i + 1) << 8) +
(s.charCodeAt(i + 2) << 16) +
(s.charCodeAt(i + 3) << 24)
}
return md5blks
}
const HEX_CHR = '0123456789abcdef'.split('')
function rhex(n) {
let s = '',
j = 0
for (; j < 4; j++)
s += HEX_CHR[(n >> (j * 8 + 4)) & 0x0f] + HEX_CHR[(n >> (j * 8)) & 0x0f]
return s
}
function hex(x) {
for (let i = 0; i < x.length; i++) x[i] = rhex(x[i])
return x.join('')
}
let add32 = (a, b) => {
return (a + b) & 0xffffffff
}
/**
* Generate an MD5 hash for an input string
* @param {string} s input string
* @returns {string} md5 hash
*/
function md5(s) {
return hex(md51(s))
}
if (md5('hello') !== '5d41402abc4b2a76b9719d911017c592') {
add32 = (x, y) => {
const lsw = (x & 0xffff) + (y & 0xffff),
msw = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xffff)
}
}
export default md5
export { md5 }
@@ -0,0 +1,4 @@
import { md5 } from '@speckle/shared'
export default md5
export { md5 }
@@ -1,6 +1,34 @@
const streamRewriteRgx = /stream(?=$|s|\W)/gi
const branchRewriteRgx = /branch(es)?(?=$|\W)/gi
const commitRewriteRgx = /commit(?=$|s|\W)/gi
export function isObjectId(id: string) {
return id.length === 32
}
export const buildModelTreeItemId = (projectId: string, fullName: string) =>
`${projectId}-${fullName}`
/**
* Converts old terminology (streams, branches, commits, etc.) to new one (projects, models, versions)
*/
export const toNewProductTerminology = (str: string): string => {
const isFirstCharUppercase = (val: string): boolean =>
val?.length ? val[0] === val[0].toUpperCase() : false
return str
.replaceAll(streamRewriteRgx, (match) =>
isFirstCharUppercase(match) ? 'Project' : 'project'
)
.replaceAll(branchRewriteRgx, (match) => {
const shouldBeUppercase = isFirstCharUppercase(match)
if (match === 'branches') {
return shouldBeUppercase ? 'Models' : 'models'
} else {
return shouldBeUppercase ? 'Model' : 'model'
}
})
.replaceAll(commitRewriteRgx, (match) =>
isFirstCharUppercase(match) ? 'Version' : 'version'
)
}
@@ -35,11 +35,6 @@ export const automationDataPageRoute = (baseUrl: string, automationId: string) =
export const threadRedirectRoute = (projectId: string, threadId: string) =>
`/projects/${projectId}/threads/${threadId}`
/**
* TODO: Page doesn't exist
*/
export const userProfileRoute = (userId: string) => `/profile/${userId}`
const buildNavigationComposable = (route: string) => () => {
const router = useRouter()
return (params?: { query?: LocationQueryRaw }) => {
@@ -15,10 +15,21 @@ export function useTheme() {
const isDarkTheme = computed(() => themeCookie.value === AppTheme.Dark)
const isLightTheme = computed(() => !isDarkTheme.value)
const setTheme = (newTheme: AppTheme) => {
themeCookie.value = newTheme
}
const toggleTheme = () => {
if (isDarkTheme.value) {
setTheme(AppTheme.Light)
} else {
setTheme(AppTheme.Dark)
}
}
return {
setTheme: (newTheme: AppTheme) => {
themeCookie.value = newTheme
},
setTheme,
toggleTheme,
isDarkTheme,
isLightTheme
}
@@ -29,3 +29,9 @@ export const editApplicationMutation = graphql(`
appUpdate(app: $app)
}
`)
export const revokeAppAccessMutation = graphql(`
mutation RevokeAppAccess($appId: String!) {
appRevokeAccess(appId: $appId)
}
`)
@@ -34,3 +34,21 @@ export const developerSettingsApplicationsQuery = graphql(`
}
}
`)
export const developerSettingsAuthorizedAppsQuery = graphql(`
query DeveloperSettingsAuthorizedApps {
activeUser {
id
authorizedApps {
id
description
name
author {
id
name
avatar
}
}
}
}
`)
@@ -2,7 +2,8 @@ import { AllScopes } from '@speckle/shared'
import type { Get } from 'type-fest'
import type {
DeveloperSettingsAccessTokensQuery,
DeveloperSettingsApplicationsQuery
DeveloperSettingsApplicationsQuery,
DeveloperSettingsAuthorizedAppsQuery
} from '~~/lib/common/generated/gql/graphql'
export type TokenItem = NonNullable<
@@ -18,6 +19,10 @@ export type ApplicationItem = NonNullable<
Get<DeveloperSettingsApplicationsQuery, 'activeUser.createdApps[0]'>
>
export type AuthorizedAppItem = NonNullable<
Get<DeveloperSettingsAuthorizedAppsQuery, 'activeUser.authorizedApps[0]'>
>
export type ApplicationFormValues = {
name: string
scopes: Array<{ id: (typeof AllScopes)[number]; text: string }>
+1 -1
View File
@@ -10,7 +10,7 @@ import { loginRoute } from '~~/lib/common/helpers/route'
export default defineNuxtRouteMiddleware(async (to) => {
const nuxt = useNuxtApp()
const client = useApolloClientFromNuxt()
const postAuthRedirect = usePostAuthRedirect()
const postAuthRedirect = usePostAuthRedirect({ route: to })
const { data } = await client
.query({
@@ -1,101 +1,184 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="flex flex-col items-center">
<LogoTextWhite class="my-6 sm:mb-14" />
<LayoutPanel class="max-w-screen-sm mx-auto w-full">
<div
v-if="activeUser && app && !action"
class="space-y-8 flex flex-col items-center"
>
<div class="space-y-2 flex flex-col items-center">
<UserAvatar :user="activeUser" size="lg" />
<div class="text-foreground h6">{{ activeUser.name }}</div>
<CommonTextLink size="xs" @click="() => logout()">
Not you? Switch accounts
</CommonTextLink>
</div>
<div class="text-foreground h4 text-center">
<span class="text-primary font-bold">
<ShieldCheckIcon
v-if="app?.trustByDefault"
class="h-6 w-6 inline-block relative -top-1"
/>
{{ app?.name }}
</span>
is requesting access to your Speckle account
</div>
<div v-if="!app.trustByDefault" class="w-full">
<Disclosure v-slot="{ open }">
<DisclosureButton
class="w-full flex justify-between items-center text-foreground px-4 py-2 rounded-lg hover:bg-foundation-focus"
<div class="mx-auto">
<LayoutPanel class="max-w-lg mx-auto w-full">
<div class="space-y-8 flex flex-col items-center">
<h1 class="text-center h3 font-bold inline-block text-foreground bg-clip-text">
Authorize Application
</h1>
<template v-if="activeUser && app && !action">
<div class="space-y-2 flex flex-col">
<div class="space-x-2 flex items-center">
<UserAvatar :user="activeUser" size="lg" />
<div class="label-light">{{ activeUser.name }}</div>
</div>
<CommonTextLink
size="xs"
:icon-right="ArrowsRightLeftIcon"
no-underline
@click="onSwitchAccounts"
>
<span class="font-bold">
App info & requested permissions ({{ app.scopes.length }})
</span>
<ChevronUpIcon
:class="!open ? 'rotate-180 transform' : ''"
class="h-5 w-5 text-primary"
Not you? Switch accounts
</CommonTextLink>
</div>
<div class="text-foreground h4 text-center">
<span class="text-primary font-bold">
<ShieldCheckIcon
v-if="trustByDefault"
class="h-6 w-6 inline-block relative -top-1"
/>
</DisclosureButton>
<DisclosurePanel
class="flex flex-col px-4 py-2 space-y-2 label label--light"
>
<div v-if="app.author" class="space-x-1 inline-flex items-center">
<span class="font-bold">Author:</span>
<span>{{ app.author.name }}</span>
<UserAvatar :user="app.author" size="sm" />
</div>
<div v-if="app.description?.length" class="space-x-1">
<span class="font-bold">Description:</span>
<span>{{ app.description }}</span>
</div>
<div>
<div class="bg-foundation-disabled h-[1px] my-2" />
</div>
<div class="font-bold">Permissions:</div>
<div v-for="scope in app.scopes" :key="scope?.name" class="space-x-1">
<span class="font-bold">{{ scope.name }}</span>
<span>{{ scope.description }}</span>
</div>
</DisclosurePanel>
</Disclosure>
{{ app?.name }}
</span>
wants to access your Speckle account.
</div>
<div v-if="!trustByDefault" class="w-full">
<Disclosure v-slot="{ open }">
<DisclosureButton
class="w-full flex justify-between items-center text-foreground px-2 py-4 border-t border-b border-outline-3"
>
<div class="flex space-x-2 items-center">
<InformationCircleIcon class="h-5 w-5 shrink-0" />
<span class="font-bold text-left">
App info & Requested permissions ({{ app.scopes.length }})
</span>
</div>
<ChevronUpIcon
:class="!open ? 'rotate-180 transform' : ''"
class="h-5 w-5 text-foreground shrink-0"
/>
</DisclosureButton>
<DisclosurePanel
class="flex flex-col px-2 py-5 space-y-5 label-light border-b border-outline-3 label-light"
>
<table v-if="app.author || app.description?.length" class="table-fixed">
<tbody>
<tr v-if="app.author">
<td class="font-bold pr-2 w-[100px]">Author:</td>
<td class="inline-flex space-x-1 items-center">
<UserAvatar :user="app.author" size="sm" />
<span>{{ app.author.name }}</span>
</td>
</tr>
<tr v-if="app.description?.length">
<td class="align-top font-bold pr-2">Description:</td>
<td>
{{ app.description }}
</td>
</tr>
</tbody>
</table>
<div class="space-y-4">
<div class="font-bold">Permissions:</div>
<!-- <ul v-if="false" class="list-disc list-inside space-y-4">
<li v-for="scope in app.scopes" :key="scope?.name">
<span>{{ scope.description }}</span>
</li>
</ul> -->
<ul class="list-inside space-y-2">
<template
v-for="[group, scope] in Object.entries(groupedScopes)"
:key="group"
>
<li>
<span class="font-bold">{{ group }}</span>
<ul class="ps-5 list-[circle] list-outside">
<li v-for="desc in scope" :key="desc">
<span>{{ desc }}</span>
</li>
</ul>
</li>
</template>
</ul>
</div>
</DisclosurePanel>
</Disclosure>
</div>
<div class="flex space-x-2 w-full">
<FormButton color="secondary" full-width :disabled="loading" @click="deny">
Deny
</FormButton>
<FormButton full-width :disabled="loading" @click="allow">
Authorize
</FormButton>
</div>
</template>
<div
v-else-if="app === null || action"
class="w-full flex flex-col items-center space-y-4"
>
<div class="flex space-x-2 items-center">
<Component
:is="
action === ChosenAction.Allow ? CheckCircleIcon : ExclamationCircleIcon
"
class="h-9 w-9"
:class="[action === ChosenAction.Allow ? 'text-success' : 'text-danger']"
/>
<span class="h3 font-bold">
<template v-if="action">
{{ action === ChosenAction.Allow ? 'Success' : 'Denied' }}
</template>
<template v-else>Error</template>
</span>
</div>
<div class="text-center">
<template v-if="app">
<template v-if="action === ChosenAction.Allow">
<span class="font-bold text-primary">{{ app?.name }}</span>
is connected to your
<span class="font-bold">Speckle</span>
account.
</template>
<template v-else>
<span class="font-bold text-primary">{{ app?.name }}</span>
has not been connected to your
<span class="font-bold">Speckle</span>
account.
</template>
</template>
<div v-else class="flex space-x-2 items-center">
<span>Could not resolve app.</span>
<CommonTextLink :to="homeRoute">Go Home</CommonTextLink>
</div>
</div>
<div v-if="action" class="label-light text-foreground-2">
You will be redirected automatically, please wait a moment.
</div>
</div>
<div class="flex space-x-2 w-full">
<FormButton color="danger" full-width size="lg" @click="deny">
Deny
</FormButton>
<FormButton full-width size="lg" @click="allow">Allow</FormButton>
</div>
<div class="w-full text-foreground-2 text-center label-light">
Clicking 'Allow' will redirect you to {{ app.redirectUrl }}
</div>
</div>
<div v-else-if="action" class="w-full flex flex-col items-center">
<span class="font-bold">
<template v-if="action === ChosenAction.Allow">Permission granted.</template>
<template v-else>Permission denied.</template>
</span>
<span class="label-light text-foreground-2">
You will be redirected automatically
</span>
</div>
<div v-else-if="app === null" class="space-x-2">
<span>Could not resolve app.</span>
<CommonTextLink :to="homeRoute">Go Home</CommonTextLink>
</div>
</LayoutPanel>
<div
v-if="serverInfo?.termsOfService"
class="mt-3 max-w-lg text-center caption font-medium text-foreground-2 terms-of-service"
v-html="serverInfo.termsOfService"
/>
</div>
</template>
<script setup lang="ts">
import { ShieldCheckIcon } from '@heroicons/vue/24/solid'
import {
ShieldCheckIcon,
CheckCircleIcon,
ExclamationCircleIcon
} from '@heroicons/vue/24/solid'
import { useQuery } from '@vue/apollo-composable'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import { useAuthCookie, useAuthManager } from '~~/lib/auth/composables/auth'
import { authorizableAppMetadataQuery } from '~~/lib/auth/graphql/queries'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
import { ChevronUpIcon } from '@heroicons/vue/20/solid'
import type { Nullable } from '@speckle/shared'
import { ensureError, type Nullable } from '@speckle/shared'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { homeRoute } from '~~/lib/common/helpers/route'
import {
ArrowsRightLeftIcon,
InformationCircleIcon,
ChevronUpIcon
} from '@heroicons/vue/24/outline'
import { useServerInfo } from '~/lib/core/composables/server'
import { upperFirst } from 'lodash-es'
import { toNewProductTerminology } from '~/lib/common/helpers/resources'
import { FetchError } from 'ofetch'
import { usePostAuthRedirect } from '~/lib/auth/composables/postAuthRedirect'
enum ChosenAction {
Allow = 'allow',
@@ -107,12 +190,20 @@ definePageMeta({
name: 'authorize-app'
})
useHead({
title: 'Authorize Application'
})
const apiOrigin = useApiOrigin()
const route = useRoute()
const { activeUser } = useActiveUser()
const authToken = useAuthCookie()
const { logout } = useAuthManager()
const mp = useMixpanel()
const { serverInfo } = useServerInfo()
const loading = ref(false)
const { triggerNotification } = useGlobalToast()
const postAuthRedirect = usePostAuthRedirect()
const appId = computed(() => route.params.appId as string)
const challenge = computed(() => route.params.challenge as string)
@@ -138,31 +229,95 @@ const allowUrl = computed(() => {
finalUrl.searchParams.set('appId', app.value.id)
finalUrl.searchParams.set('challenge', challenge.value)
finalUrl.searchParams.set('token', authToken.value)
finalUrl.searchParams.set('preventRedirect', 'true')
return finalUrl.toString()
})
const deny = () => {
if (process.server || !denyUrl.value || !activeUser.value) return
const translatedScopes = computed(() => {
return app.value?.scopes.map((scope) => {
return {
description: toNewProductTerminology(scope.description),
name: toNewProductTerminology(scope.name)
}
})
})
const trustByDefault = computed(() => {
return app.value?.trustByDefault
})
const groupedScopes = computed(() => {
if (!translatedScopes.value) return []
return translatedScopes.value.reduce((acc, scope) => {
const key = upperFirst(scope.name.split(':')[0])
if (!acc[key]) acc[key] = []
acc[key].push(scope.description)
return acc
}, {} as Record<string, string[]>)
})
const goToFinalUrl = (url: string) => {
// waiting 2 seconds before actually redirecting
setTimeout(() => {
window.location.replace(url)
}, 2000)
}
const deny = () => {
if (process.server || !denyUrl.value || !activeUser.value || loading.value) return
loading.value = true
action.value = ChosenAction.Deny
mp.track('App Authorization', { allow: false, type: 'action' })
window.location.replace(denyUrl.value)
goToFinalUrl(denyUrl.value)
}
const allow = () => {
if (process.server || !allowUrl.value) return
const allow = async () => {
if (process.server || !allowUrl.value || loading.value) return
action.value = ChosenAction.Allow
loading.value = true
mp.track('App Authorization', { allow: true, type: 'action' })
window.location.replace(allowUrl.value)
try {
const allowRes = await $fetch<{ redirectUrl: string }>(allowUrl.value)
if (!allowRes?.redirectUrl) {
throw new Error('Malformed authorization response, please contact site admins.')
}
// Finally redirect
action.value = ChosenAction.Allow
goToFinalUrl(allowRes.redirectUrl)
} catch (err) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'App authorization failed',
description:
err instanceof FetchError
? (err.data as string) || err.statusMessage || err.message
: ensureError(err).message,
autoClose: false
})
} finally {
loading.value = false
}
}
watch(
() => !!app.value?.trustByDefault,
(trustByDefault) => {
if (trustByDefault) allow()
},
{ immediate: true }
)
const onSwitchAccounts = async () => {
const path = route.fullPath
await logout()
postAuthRedirect.set(path, true)
}
if (process.client) {
watch(
() => trustByDefault.value,
(newVal) => {
if (newVal) void allow()
},
{ immediate: true }
)
}
</script>
@@ -26,6 +26,7 @@
<div class="flex flex-col gap-4">
<DeveloperSettingsSectionHeader
title="Access Tokens"
subheading
:buttons="[
{
props: {
@@ -72,7 +73,7 @@
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
class: 'text-red-500'
textColor: 'danger'
}
]"
>
@@ -104,6 +105,7 @@
<div class="flex flex-col gap-4">
<DeveloperSettingsSectionHeader
subheading
title="Applications"
:buttons="[
{
@@ -145,19 +147,19 @@
icon: LockOpenIcon,
label: 'Reveal Secret',
action: openRevealSecretDialog,
class: 'text-primary'
textColor: 'primary'
},
{
icon: PencilIcon,
label: 'Edit',
action: openEditApplicationDialog,
class: 'text-primary'
textColor: 'primary'
},
{
icon: TrashIcon,
label: 'Delete',
action: openDeleteDialog,
class: 'text-red-500'
textColor: 'danger'
}
]"
>
@@ -184,6 +186,64 @@
</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
@@ -220,18 +280,25 @@ import {
BookOpenIcon,
TrashIcon,
PencilIcon,
LockOpenIcon
LockOpenIcon,
XMarkIcon
} from '@heroicons/vue/24/outline'
import type {
TokenItem,
ApplicationItem
ApplicationItem,
AuthorizedAppItem
} from '~~/lib/developer-settings/helpers/types'
import {
developerSettingsAccessTokensQuery,
developerSettingsApplicationsQuery
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(
@@ -240,8 +307,9 @@ const { result: tokensResult, refetch: refetchTokens } = useQuery(
const { result: applicationsResult, refetch: refetchApplications } = useQuery(
developerSettingsApplicationsQuery
)
const { result: authorizedAppsResult } = useQuery(developerSettingsAuthorizedAppsQuery)
const itemToModify = ref<TokenItem | ApplicationItem | null>(null)
const itemToModify = ref<TokenItem | ApplicationItem | AuthorizedAppItem | null>(null)
const tokenSuccess = ref('')
const showCreateTokenDialog = ref(false)
const showCreateTokenSuccessDialog = ref(false)
@@ -262,7 +330,13 @@ const applications = computed<ApplicationItem[]>(() => {
return applicationsResult.value?.activeUser?.createdApps || []
})
const openDeleteDialog = (item: TokenItem | ApplicationItem) => {
const authorizedApps = computed(() =>
(authorizedAppsResult.value?.activeUser?.authorizedApps || []).filter(
(app) => app.id !== 'spklwebapp'
)
)
const openDeleteDialog = (item: TokenItem | ApplicationItem | AuthorizedAppItem) => {
itemToModify.value = item
showDeleteDialog.value = true
}
+3
View File
@@ -0,0 +1,3 @@
import { ToastNotificationType } from '~~/lib/common/composables/toast'
export { ToastNotificationType }
@@ -46,7 +46,7 @@ extend type User {
"""
Returns the apps you have authorized.
"""
authorizedApps: [ServerAppListItem]
authorizedApps: [ServerAppListItem!]
@hasServerRole(role: SERVER_GUEST)
@hasScope(scope: "apps:read")
+6 -1
View File
@@ -24,6 +24,7 @@ module.exports = (app) => {
*/
app.get('/auth/accesscode', async (req, res) => {
try {
const preventRedirect = !!req.query.preventRedirect
const appId = req.query.appId
const app = await getApp({ id: appId })
@@ -42,7 +43,11 @@ module.exports = (app) => {
await validateScopes(scopes, Scopes.Tokens.Write)
const ac = await createAuthorizationCode({ appId, userId, challenge })
return res.redirect(`${app.redirectUrl}?access_code=${ac}`)
const redirectUrl = `${app.redirectUrl}?access_code=${ac}`
return preventRedirect
? res.status(200).json({ redirectUrl })
: res.redirect(redirectUrl)
} catch (err) {
if (
err instanceof InvalidAccessCodeRequestError ||
+2 -2
View File
@@ -5,12 +5,12 @@ const { Scopes } = require('@speckle/shared')
module.exports = [
{
name: Scopes.Apps.Read,
description: 'See what applications you have created or have authorized.',
description: 'See created or authorized applications.',
public: false
},
{
name: Scopes.Apps.Write,
description: 'Register applications on your behalf.',
description: 'Register new applications.',
public: false
}
]
+1 -1
View File
@@ -8,7 +8,7 @@ async function initScopes() {
const scopes: ScopeRecord[] = [
{
name: Scopes.Automate.ReportResults,
description: 'Allows the app to report automation results to the server.',
description: 'Report automation results to the server.',
public: true
}
]
@@ -2616,7 +2616,7 @@ export type User = {
/** Returns a list of your personal api tokens. */
apiTokens: Array<ApiToken>;
/** Returns the apps you have authorized. */
authorizedApps?: Maybe<Array<Maybe<ServerAppListItem>>>;
authorizedApps?: Maybe<Array<ServerAppListItem>>;
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
/**
@@ -4242,7 +4242,7 @@ export type TokenResourceIdentifierResolvers<ContextType = GraphQLContext, Paren
export type UserResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User']> = {
activity?: Resolver<Maybe<ResolversTypes['ActivityCollection']>, ParentType, ContextType, RequireFields<UserActivityArgs, 'limit'>>;
apiTokens?: Resolver<Array<ResolversTypes['ApiToken']>, ParentType, ContextType>;
authorizedApps?: Resolver<Maybe<Array<Maybe<ResolversTypes['ServerAppListItem']>>>, ParentType, ContextType>;
authorizedApps?: Resolver<Maybe<Array<ResolversTypes['ServerAppListItem']>>, ParentType, ContextType>;
avatar?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
bio?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
commits?: Resolver<Maybe<ResolversTypes['CommitCollection']>, ParentType, ContextType, RequireFields<UserCommitsArgs, 'limit'>>;
+9 -9
View File
@@ -16,49 +16,49 @@ module.exports = [
},
{
name: Scopes.Profile.Read,
description: 'Read your profile information (name, bio, company).',
description: 'Read your profile information.',
public: true
},
{
name: Scopes.Profile.Email,
description: 'Grants access to the email address you registered with.',
description: 'Read the email address you registered with.',
public: true
},
{
name: Scopes.Profile.Delete,
description: 'Allows a user to delete their account, with all associated data.',
description: 'Delete the account with all associated data.',
public: false
},
{
name: Scopes.Users.Read,
description: "Read other users' profile on your behalf.",
description: "Read other users' profiles.",
public: true
},
{
name: Scopes.Server.Stats,
description:
'Request server stats from the api. Only works in conjunction with a "server:admin" role.',
'Request server stats from the API. Only works in conjunction with a "server:admin" role.',
public: true
},
{
name: Scopes.Users.Email,
description: 'Access the emails of other users on your behalf.',
description: 'Access the emails of other users.',
public: false
},
{
name: Scopes.Server.Setup,
description:
'Edit server information. Note: only server admins will be able to use this token.',
'Edit server information. Note: Only server admins will be able to use this token.',
public: false
},
{
name: Scopes.Tokens.Read,
description: 'Access your api tokens.',
description: 'Access API tokens.',
public: false
},
{
name: Scopes.Tokens.Write,
description: 'Create and delete api tokens on your behalf.',
description: 'Create and delete API tokens.',
public: false
}
]
@@ -2606,7 +2606,7 @@ export type User = {
/** Returns a list of your personal api tokens. */
apiTokens: Array<ApiToken>;
/** Returns the apps you have authorized. */
authorizedApps?: Maybe<Array<Maybe<ServerAppListItem>>>;
authorizedApps?: Maybe<Array<ServerAppListItem>>;
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
/**
@@ -2607,7 +2607,7 @@ export type User = {
/** Returns a list of your personal api tokens. */
apiTokens: Array<ApiToken>;
/** Returns the apps you have authorized. */
authorizedApps?: Maybe<Array<Maybe<ServerAppListItem>>>;
authorizedApps?: Maybe<Array<ServerAppListItem>>;
avatar?: Maybe<Scalars['String']>;
bio?: Maybe<Scalars['String']>;
/**
+2 -2
View File
@@ -1,7 +1,7 @@
export default {
plugins: {
'tailwindcss/nesting': {},
tailwindcss: {},
autoprefixer: {},
'postcss-nesting': {}
autoprefixer: {}
}
}
@@ -0,0 +1,15 @@
/**
* Tippy mentions theme
*/
.tippy-box[data-theme='mention'] {
background: none;
pointer-events: none;
.tippy-arrow {
display: none;
}
.tippy-content > * {
pointer-events: auto;
}
}
@@ -1,6 +1,7 @@
<template>
<FormButton
link
:link="!noUnderline"
:text="noUnderline"
:to="to"
:external="external"
:disabled="disabled"
@@ -68,6 +69,10 @@ const props = defineProps({
hideText: {
type: Boolean,
default: false
},
noUnderline: {
type: Boolean,
default: false
}
})
@@ -11,7 +11,16 @@ export default {
component: FormButton,
argTypes: {
color: {
options: ['default', 'invert', 'danger', 'warning', 'secondary', 'info'],
options: [
'default',
'invert',
'danger',
'warning',
'success',
'card',
'secondary',
'info'
],
control: { type: 'select' }
},
outlined: {
@@ -153,6 +162,13 @@ export const SecondaryButton: StoryObj = mergeStories(Default, {
}
})
export const SecondaryWithCustomColor: StoryObj = mergeStories(Default, {
args: {
color: 'secondary',
textColor: 'danger'
}
})
export const InvertButton: StoryObj = mergeStories(Default, {
args: {
color: 'invert'
@@ -30,17 +30,9 @@ import type { PropAnyComponent } from '~~/src/helpers/common/components'
import { computed, resolveDynamicComponent } from 'vue'
import type { Nullable, Optional } from '@speckle/shared'
import { ArrowPathIcon } from '@heroicons/vue/24/solid'
import type { FormButtonColor, FormButtonTextColor } from '~~/src/helpers/form/button'
type FormButtonSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl'
type FormButtonColor =
| 'default'
| 'invert'
| 'danger'
| 'warning'
| 'success'
| 'card'
| 'secondary'
| 'info'
const emit = defineEmits<{
/**
@@ -111,6 +103,13 @@ const props = defineProps({
type: String as PropType<FormButtonColor>,
default: 'default'
},
/**
* Text color, only used when color=secondary
*/
textColor: {
type: String as PropType<FormButtonTextColor>,
default: 'default'
},
/**
* Whether the target location should be forcefully treated as an external URL
* (for relative paths this will likely cause a redirect)
@@ -253,12 +252,32 @@ const bgAndBorderClasses = computed(() => {
const foregroundClasses = computed(() => {
const classParts: string[] = []
const hasCustomTextColor = props.textColor !== 'default'
if (hasCustomTextColor && !isDisabled.value) {
switch (props.textColor) {
case 'primary':
classParts.push('text-primary')
break
case 'warning':
classParts.push('text-warning')
break
case 'success':
classParts.push('text-success')
break
case 'danger':
classParts.push('text-danger')
break
case 'info':
classParts.push('text-info')
break
}
}
if (!props.text && !props.link) {
if (isDisabled.value) {
classParts.push(
props.outlined ? 'text-foreground-disabled' : 'text-foreground-disabled'
)
} else {
classParts.push('text-foreground-disabled')
} else if (!hasCustomTextColor) {
switch (props.color) {
case 'invert':
classParts.push(
@@ -308,7 +327,7 @@ const foregroundClasses = computed(() => {
} else {
if (isDisabled.value) {
classParts.push('text-foreground-disabled')
} else {
} else if (!hasCustomTextColor) {
if (props.color === 'invert') {
classParts.push(
'text-foundation hover:text-foundation-2 dark:text-foreground dark:hover:text-foreground'
@@ -5,6 +5,9 @@ import ToastRenderer from '~~/src/components/global/ToastRenderer.vue'
import FormButton from '~~/src/components/form/Button.vue'
import { ToastNotificationType } from '~~/src/helpers/global/toast'
import type { ToastNotification } from '~~/src/helpers/global/toast'
import { useGlobalToast } from '~~/src/stories/composables/toast'
type StoryType = StoryObj<{ notification: ToastNotification }>
export default {
component: ToastRenderer,
@@ -27,31 +30,20 @@ export default {
}
} as Meta
export const Default: StoryObj = {
export const Default: StoryType = {
render: (args) => ({
components: { ToastRenderer, FormButton },
setup() {
const { triggerNotification } = useGlobalToast()
const notification = ref(null as Nullable<ToastNotification>)
const onClick = () => {
notification.value = {
type: ToastNotificationType.Info,
title: 'Title',
description: 'Description',
cta: {
title: 'CTA',
onClick: () => console.log('Clicked')
}
}
// Clear after 2s
setTimeout(() => (notification.value = null), 2000)
triggerNotification(args.notification)
}
return { args, onClick, notification }
},
template: `
<div>
<FormButton @click="onClick">Trigger!</FormButton>
<ToastRenderer v-model:notification="notification"/>
</div>
`
}),
@@ -61,5 +53,26 @@ export const Default: StoryObj = {
code: '<GlobalToastRenderer v-model:notification="notification"/>'
}
}
},
args: {
notification: {
type: ToastNotificationType.Info,
title: 'Title',
description: 'Description',
cta: {
title: 'CTA',
onClick: () => console.log('Clicked')
}
}
}
}
export const WithManualClose: StoryType = {
...Default,
args: {
notification: {
...Default.args!.notification!,
autoClose: false
}
}
}
@@ -22,9 +22,8 @@
<div
v-for="item in items"
:key="item.id"
class="relative grid grid-cols-12 items-center gap-6 px-4 py-1 min-w-[900px] bg-foundation"
:style="{ paddingRight: paddingRightStyle }"
:class="{ 'cursor-pointer hover:bg-primary-muted': !!onRowClick }"
:class="rowsWrapperClasses"
tabindex="0"
@click="handleRowClick(item)"
@keypress="handleRowClick(item)"
@@ -36,7 +35,7 @@
</slot>
</div>
</template>
<div class="absolute right-1.5 gap-1 flex items-center p-0">
<div class="absolute right-1.5 gap-1 flex items-center p-0 h-full">
<div v-for="button in buttons" :key="button.label">
<FormButton
:icon-left="button.icon"
@@ -44,7 +43,9 @@
color="secondary"
hide-text
:class="button.class"
@click.stop="button.action(item)"
:text-color="button.textColor"
:to="isString(button.action) ? button.action : undefined"
@click.stop="!isString(button.action) ? button.action(item) : noop"
/>
</div>
</div>
@@ -55,8 +56,10 @@
</template>
<script setup lang="ts" generic="T extends {id: string}, C extends string">
import { noop, isString } from 'lodash'
import { computed } from 'vue'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import type { FormButtonTextColor } from '~~/src/helpers/form/button'
import { FormButton } from '~~/src/lib'
export type TableColumn<I> = {
@@ -68,17 +71,22 @@ export type TableColumn<I> = {
export interface RowButton<T = unknown> {
icon: PropAnyComponent
label: string
action: (item: T) => void
class: string
action: (item: T) => void | string
class?: string
textColor?: FormButtonTextColor
}
const props = defineProps<{
items: T[]
buttons?: RowButton<T>[]
columns: TableColumn<C>[]
overflowCells?: boolean
onRowClick?: (item: T) => void
}>()
const props = withDefaults(
defineProps<{
items: T[]
buttons?: RowButton<T>[]
columns: TableColumn<C>[]
overflowCells?: boolean
onRowClick?: (item: T) => void
rowItemsAlign?: 'center' | 'stretch'
}>(),
{ rowItemsAlign: 'center' }
)
const paddingRightStyle = computed(() => {
const buttonCount = (props.buttons || []).length
@@ -89,6 +97,27 @@ const paddingRightStyle = computed(() => {
return `${padding}px`
})
const rowsWrapperClasses = computed(() => {
const classParts = [
'relative grid grid-cols-12 items-center gap-6 px-4 py-1 min-w-[900px] bg-foundation'
]
if (props.onRowClick) {
classParts.push('cursor-pointer hover:bg-primary-muted')
}
switch (props.rowItemsAlign) {
case 'center':
classParts.push('items-center')
break
case 'stretch':
classParts.push('items-stretch')
break
}
return classParts.join(' ')
})
const getHeaderClasses = (column: C): string => {
return props.columns.find((c) => c.id === column)?.classes || ''
}
@@ -0,0 +1,17 @@
export type FormButtonColor =
| 'default'
| 'invert'
| 'danger'
| 'warning'
| 'success'
| 'card'
| 'secondary'
| 'info'
export type FormButtonTextColor =
| 'default'
| 'primary'
| 'warning'
| 'success'
| 'danger'
| 'info'
@@ -20,4 +20,9 @@ export type ToastNotification = {
url?: string
onClick?: (e: MouseEvent) => void
}
/**
* Whether or not the toast should disappear automatically after a while.
* Defaults to true
*/
autoClose?: boolean
}
+1
View File
@@ -1,4 +1,5 @@
import 'tippy.js/dist/tippy.css'
import './assets/setup/mentions.css'
import GlobalToastRenderer from '~~/src/components/global/ToastRenderer.vue'
import { ToastNotificationType } from '~~/src/helpers/global/toast'
import type { ToastNotification } from '~~/src/helpers/global/toast'
@@ -40,7 +40,7 @@ export function useGlobalToastManager() {
// (re-)init timeout
stop()
start()
if (newVal.autoClose !== false) start()
})
},
{ deep: true }