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:
committed by
GitHub
parent
c4ce83ed2a
commit
6af6c656a4
@@ -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 }>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'>>;
|
||||
|
||||
@@ -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']>;
|
||||
/**
|
||||
|
||||
@@ -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,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 }
|
||||
|
||||
Reference in New Issue
Block a user