Browse Source

Merge pull request #2854 from nocodb/refactor/gui-v2-added-user-management

User management  and API Token Management added
pull/2924/head
navi 2 years ago committed by GitHub
parent
commit
e3b026b71e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui-v2/components.d.ts
  2. 6
      packages/nc-gui-v2/components/dashboard/settings/SettingsModal.vue
  3. 42
      packages/nc-gui-v2/components/tabs/Auth.vue
  4. 200
      packages/nc-gui-v2/components/tabs/auth/ApiTokenManagement.vue
  5. 301
      packages/nc-gui-v2/components/tabs/auth/UserManagement.vue
  6. 31
      packages/nc-gui-v2/components/tabs/auth/user-management/FeedbackForm.vue
  7. 229
      packages/nc-gui-v2/components/tabs/auth/user-management/ShareBase.vue
  8. 238
      packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue
  9. 18
      packages/nc-gui-v2/composables/useGlobalState/index.ts
  10. 5
      packages/nc-gui-v2/composables/useGlobalState/initialFeedBackForm.ts
  11. 7
      packages/nc-gui-v2/lib/enums.ts
  12. 8
      packages/nc-gui-v2/lib/types.ts
  13. 38
      packages/nc-gui-v2/package-lock.json
  14. 2
      packages/nc-gui-v2/package.json
  15. 16
      packages/nc-gui-v2/pages/nc/[projectId]/index.vue
  16. 8
      packages/nc-gui-v2/pages/nc/[projectId]/index/index/auth.vue
  17. 2
      packages/nc-gui-v2/plugins/api.ts
  18. 39
      packages/nc-gui-v2/plugins/initializeFeedbackForm.ts
  19. 2
      packages/nc-gui-v2/utils/dateTimeUtils.ts
  20. 4
      packages/nc-gui-v2/utils/urlUtils.ts
  21. 8
      packages/nc-gui-v2/utils/userUtils.ts

1
packages/nc-gui-v2/components.d.ts vendored

@ -19,6 +19,7 @@ declare module '@vue/runtime-core' {
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADivider: typeof import('ant-design-vue/es')['Divider']
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
ADropdownButton: typeof import('ant-design-vue/es')['DropdownButton']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AInput: typeof import('ant-design-vue/es')['Input']

6
packages/nc-gui-v2/components/dashboard/settings/SettingsModal.vue

@ -4,6 +4,8 @@ import AuditTab from './AuditTab.vue'
import AppStore from './AppStore.vue'
import Metadata from './Metadata.vue'
import UIAcl from './UIAcl.vue'
import ApiTokenManagement from '~/components/tabs/auth/ApiTokenManagement.vue'
import UserManagement from '~/components/tabs/auth/UserManagement.vue'
import StoreFrontOutline from '~icons/mdi/storefront-outline'
import TeamFillIcon from '~icons/ri/team-fill'
import MultipleTableIcon from '~icons/mdi/table-multiple'
@ -39,11 +41,11 @@ const tabsInfo: TabGroup = {
subTabs: {
usersManagement: {
title: 'Users Management',
body: () => AuditTab,
body: () => UserManagement,
},
apiTokenManagement: {
title: 'API Token Management',
body: () => AuditTab,
body: () => ApiTokenManagement,
},
},
},

42
packages/nc-gui-v2/components/tabs/Auth.vue

@ -1,5 +1,43 @@
<script setup lang="ts">
import UserManagement from './auth/UserManagement.vue'
import ApiTokenManagement from './auth/ApiTokenManagement.vue'
interface TabGroup {
[key: string]: {
title: string
body: any
}
}
const tabsInfo: TabGroup = {
usersManagement: {
title: 'Users Management',
body: () => UserManagement,
},
apiTokenManagement: {
title: 'API Token Management',
body: () => ApiTokenManagement,
},
}
const firstKeyOfObject = (obj: object) => Object.keys(obj)[0]
const selectedTabKeys = $ref<string[]>([firstKeyOfObject(tabsInfo)])
const selectedTab = $computed(() => tabsInfo[selectedTabKeys[0]])
</script>
<template>
<div>
<h2 class="text-3xl mt-3">Team & Auth</h2>
<div class="mt-2">
<a-menu v-model:selectedKeys="selectedTabKeys" :open-keys="[]" mode="horizontal">
<a-menu-item v-for="(tab, key) of tabsInfo" :key="key" class="select-none">
<div class="text-xs pb-2.5">
{{ tab.title }}
</div>
</a-menu-item>
</a-menu>
<div class="mx-4 py-6 mt-2">
<component :is="selectedTab.body()" />
</div>
</div>
</template>

200
packages/nc-gui-v2/components/tabs/auth/ApiTokenManagement.vue

@ -0,0 +1,200 @@
<script setup lang="ts">
import type { ApiTokenType } from 'nocodb-sdk'
import { useToast } from 'vue-toastification'
import { useClipboard } from '@vueuse/core'
import KebabIcon from '~icons/ic/baseline-more-vert'
import MdiPlusIcon from '~icons/mdi/plus'
import CloseIcon from '~icons/material-symbols/close-rounded'
import ReloadIcon from '~icons/mdi/reload'
import VisibilityOpenIcon from '~icons/material-symbols/visibility'
import VisibilityCloseIcon from '~icons/material-symbols/visibility-off'
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline'
import MdiContentCopyIcon from '~icons/mdi/content-copy'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
const toast = useToast()
interface ApiToken extends ApiTokenType {
show?: boolean
}
const { $api, $e } = useNuxtApp()
const { project } = $(useProject())
const { copy } = useClipboard()
let tokensInfo = $ref<ApiToken[] | undefined>([])
let showNewTokenModal = $ref(false)
let showDeleteTokenModal = $ref(false)
let selectedTokenData = $ref<ApiToken>({})
const loadApiTokens = async () => {
if (!project?.id) return
tokensInfo = await $api.apiToken.list(project.id)
}
const openNewTokenModal = () => {
showNewTokenModal = true
$e('c:api-token:generate')
}
const copyToken = (token: string | undefined) => {
if (!token) return
copy(token)
toast.info('Copied to clipboard')
$e('c:api-token:copy')
}
const generateToken = async () => {
try {
if (!project?.id) return
await $api.apiToken.create(project.id, selectedTokenData)
showNewTokenModal = false
toast.success('Token generated successfully')
selectedTokenData = {}
await loadApiTokens()
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:generate')
}
const deleteToken = async () => {
try {
if (!project?.id || !selectedTokenData.token) return
await $api.apiToken.delete(project.id, selectedTokenData.token)
toast.success('Token deleted successfully')
await loadApiTokens()
showDeleteTokenModal = false
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:api-token:delete')
}
const openDeleteModal = (item: ApiToken) => {
selectedTokenData = item
showDeleteTokenModal = true
}
onMounted(() => {
loadApiTokens()
})
</script>
<template>
<a-modal v-model:visible="showNewTokenModal" :closable="false" width="28rem" centered :footer="null">
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<CloseIcon class="flex mx-auto" />
</template>
</a-button>
<div class="flex flex-row justify-center w-full -mt-1">
<a-typography-title :level="5">Generate Token</a-typography-title>
</div>
<div class="flex flex-col mt-3 justify-center space-y-6">
<a-input v-model:value="selectedTokenData.description" placeholder="Description" />
<div class="flex flex-row justify-center">
<a-button type="primary" @click="generateToken"> Generate </a-button>
</div>
</div>
</div>
</a-modal>
<a-modal v-model:visible="showDeleteTokenModal" :closable="false" width="28rem" centered :footer="null">
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">This action will remove this API Token</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showDeleteTokenModal = false"> Cancel </a-button>
<a-button type="primary" danger @click="deleteToken()"> Confirm </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-col px-10 mt-6">
<div class="flex flex-row justify-end">
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadApiTokens()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<ReloadIcon class="text-gray-500" />
<div class="text-gray-500">Reload</div>
</div>
</a-button>
<a-button size="middle" @click="openNewTokenModal">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MdiPlusIcon />
<div>Add New Token</div>
</div>
</a-button>
</div>
</div>
<div v-if="tokensInfo" class="w-full flex flex-col mt-2 px-1">
<div class="flex flex-row border-b-1 text-gray-600 text-xs pb-2 pt-2">
<div class="flex w-4/10 pl-2">Description</div>
<div class="flex w-4/10 justify-center">Token</div>
<div class="flex w-2/10 justify-end pr-2">Actions</div>
</div>
<div v-for="(item, index) in tokensInfo" :key="index" class="flex flex-col">
<div class="flex flex-row border-b-1 items-center px-2 py-2">
<div class="flex flex-row w-4/10 flex-wrap overflow-ellipsis">
{{ item.description }}
</div>
<div class="flex w-4/10 justify-center flex-wrap overflow-ellipsis">
<span v-if="item.show">{{ item.token }}</span>
<span v-else>****************************************</span>
</div>
<div class="flex flex-row w-2/10 justify-end">
<a-tooltip placement="bottom">
<template #title>
<span v-if="item.show">Hide API token </span>
<span v-else>Show API token </span>
</template>
<a-button type="text" class="!rounded-md" @click="item.show = !item.show">
<template #icon>
<VisibilityCloseIcon v-if="item.show" class="flex mx-auto h-[1.1rem]" />
<VisibilityOpenIcon v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> Copy token to clipboard </template>
<a-button type="text" class="!rounded-md" @click="copyToken(item.token)">
<template #icon>
<MdiContentCopyIcon class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<KebabIcon />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-1 h-[1rem]" @click="openDeleteModal(item)">
<MdiDeleteOutlineIcon class="flex" />
<div class="text-xs pl-2">Remove API Token</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
</div>
</div>
</template>

301
packages/nc-gui-v2/components/tabs/auth/UserManagement.vue

@ -0,0 +1,301 @@
<script setup lang="ts">
import KebabIcon from '~icons/ic/baseline-more-vert'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import UsersModal from './user-management/UsersModal.vue'
import { projectRoleTagColors } from '~/utils/userUtils'
import MidAccountIcon from '~icons/mdi/account-outline'
import ReloadIcon from "~icons/mdi/reload"
import MdiEditIcon from '~icons/ic/round-edit'
import SearchIcon from '~icons/ic/round-search'
import { useClipboard } from '@vueuse/core'
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline'
import EmailIcon from '~icons/eva/email-outline'
import MdiPlusIcon from '~icons/mdi/plus'
import MdiContentCopyIcon from '~icons/mdi/content-copy'
import MdiEmailSendIcon from '~icons/mdi/email-arrow-right-outline'
import RolesIcon from '~icons/mdi/drama-masks'
import { User } from '~/lib/types'
import { watchDebounced } from '@vueuse/core'
import { useToast } from 'vue-toastification'
import FeedbackForm from './user-management/FeedbackForm.vue'
const toast = useToast()
const { $api, $e } = useNuxtApp()
const { project } = useProject()
const { copy } = useClipboard()
let users = $ref<null | User[]>(null)
let selectedUser = $ref<null | User>(null)
let showUserModal = $ref(false)
let showUserDeleteModal = $ref(false)
let isLoading = $ref(false)
let totalRows = $ref(0)
const currentPage = $ref(1)
const currentLimit = $ref(10)
const searchText = ref<string>('')
const loadUsers = async (page = currentPage, limit = currentLimit) => {
try {
if (!project.value?.id) return
// TODO: Types of api is not correct
const response = await $api.auth.projectUserList(project.value?.id, <any> {
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
})
if (!response.users) return
totalRows = response.users.pageInfo.totalRows ?? 0
users = response.users.list as User[]
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUser = async (user: User) => {
try {
if (!project.value?.id) return
await $api.auth.projectUserAdd(project.value.id, user);
toast.success('Successfully added user to project');
await loadUsers();
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:add');
}
const deleteUser = async () => {
try {
if (!project.value?.id || !selectedUser?.id) return
await $api.auth.projectUserRemove(project.value.id, selectedUser.id);
toast.success('Successfully deleted user from project');
await loadUsers();
showUserDeleteModal = false;
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:delete');
}
const onEdit = (user: User) => {
selectedUser = user
showUserModal = true
}
const onInvite = () => {
selectedUser = null
showUserModal = true
}
const onDelete = (user: User) => {
selectedUser = user
showUserDeleteModal = true
}
const resendInvite = async (user: User) => {
if (!project.value?.id ) return
try {
await $api.auth.projectUserResendInvite(project.value.id, user.id);
toast.success('Invite email sent successfully');
await loadUsers();
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:user:resend-invite');
}
const copyInviteUrl = (user: User) => {
if(!user.invite_token) return
const getInviteUrl = (token: string) => `${location.origin}${location.pathname}#/user/authentication/signup/${token}`;
copy(getInviteUrl(user.invite_token) );
toast.success('Invite url copied to clipboard');
}
onMounted(async () => {
if (!users) {
isLoading = true
try {
await loadUsers()
} finally {
isLoading = false
}
}
})
watchDebounced(
searchText,
() => loadUsers(),
{ debounce: 300, maxWait: 600 },
)
</script>
<template>
<div v-if="isLoading" class="h-full w-full flex flex-row justify-center mt-42">
<a-spin size="large"/>
</div>
<div v-else class="flex flex-col w-full px-6">
<UsersModal :key="showUserModal" :show="showUserModal" :selected-user="selectedUser" @closed="showUserModal = false" @reload="loadUsers()"/>
<a-modal v-model:visible="showUserDeleteModal" :closable="false" width="28rem" centered :footer="null">
<div class="flex flex-col h-full">
<div class="flex flex-row justify-center mt-2 text-center w-full text-base">
This action will remove this user from this project
</div>
<div class="flex mt-6 justify-center space-x-2">
<a-button @click="showUserDeleteModal = false"> Cancel </a-button>
<a-button type="primary" danger @click="deleteUser"> Confirm </a-button>
</div>
</div>
</a-modal>
<div class="flex flex-row mb-4 mx-4 justify-between">
<div class="flex w-1/3" >
<a-input v-model:value="searchText" placeholder="Filter by email" >
<template #prefix>
<SearchIcon class="text-gray-400"/>
</template>
</a-input>
</div>
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadUsers()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<ReloadIcon class="text-gray-500" />
<div class="text-gray-500">Reload</div>
</div>
</a-button>
<a-button size="middle" @click="onInvite">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MidAccountIcon />
<div>Invite Team</div>
</div>
</a-button>
</div>
</div>
<div class="px-5">
<div class="flex flex-row border-b-1 pb-2 px-2">
<div class="flex flex-row w-4/6 space-x-1 items-center pl-1">
<EmailIcon class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs space-x-1">E-mail</div>
</div>
<div class="flex flex-row justify-center w-1/6 space-x-1 items-center pl-1">
<RolesIcon class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs">Role</div>
</div>
<div class="flex flex-row w-1/6 justify-end items-center pl-1">
<div class="text-gray-600 text-xs">Actions</div>
</div>
</div>
<div v-for="(user, index) in users" :key="index" class="flex flex-row items-center border-b-1 py-2 px-2">
<div class="flex w-4/6 flex-wrap">
{{ user.email }}
</div>
<div class="flex w-1/6 justify-center flex-wrap ml-4">
<div :class="`rounded-full px-2 py-1 bg-[${projectRoleTagColors[user.roles]}]`">
{{ user.roles }}
</div>
</div>
<div class="flex w-1/6 flex-wrap justify-end">
<a-tooltip v-if="user.project_id" placement="bottom">
<template #title>
<span>Edit user</span>
</template>
<a-button type="text" class="!rounded-md" @click="onEdit(user)">
<template #icon>
<MdiEditIcon class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="!user.project_id" placement="bottom">
<template #title>
<span>Add user to the project</span>
</template>
<a-button type="text" class="!rounded-md " @click="inviteUser(user)">
<template #icon>
<MdiPlusIcon class="flex mx-auto h-[1.1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-else placement="bottom">
<template #title>
<span>Remove user from the project</span>
</template>
<a-button type="text" class="!rounded-md" @click="onDelete(user)">
<template #icon>
<MdiDeleteOutlineIcon class="flex mx-auto h-[1.1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight">
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<KebabIcon />
</div>
</a-button>
</div>
<template #overlay>
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="resendInvite(user)">
<MdiEmailSendIcon class="flex h-[1rem]" />
<div class="text-xs pl-2">
Resend invite email
</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="copyInviteUrl(user)">
<MdiContentCopyIcon class="flex h-[1rem]" />
<div class="text-xs pl-2">
Copy invite URL
</div>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<a-pagination
hideOnSinglePage
v-model:current="currentPage"
class="mt-4"
:page-size="currentLimit"
:total="totalRows"
show-less-items
@change="loadUsers"
/>
<FeedbackForm />
</div>
</div>
</template>
<style scoped>
.users-table {
/* equally spaced columns in table */
table-layout: fixed;
width: 100%;
}
</style>

31
packages/nc-gui-v2/components/tabs/auth/user-management/FeedbackForm.vue

@ -0,0 +1,31 @@
<script setup lang="ts">
import CloseIcon from '~icons/material-symbols/close-rounded'
const { feedbackForm } = $(useGlobalState())
</script>
<template>
<div v-if="feedbackForm && !feedbackForm.isHidden" class="nc-feedback-form-wrapper mt-6">
<CloseIcon class="nc-close-icon" @click="feedbackForm.isHidden = true" />
<iframe :src="feedbackForm.url" width="100%" height="500" frameborder="0" marginheight="0" marginwidth="0">Loading </iframe>
</div>
<div v-else />
</template>
<style scoped lang="scss">
.nc-feedback-form-wrapper {
width: 100%;
position: relative;
iframe {
margin: 0 auto;
}
.nc-close-icon {
position: absolute;
top: 5px;
right: 10px;
}
}
</style>

229
packages/nc-gui-v2/components/tabs/auth/user-management/ShareBase.vue

@ -0,0 +1,229 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { useClipboard } from '@vueuse/core'
import OpenInNewIcon from '~icons/mdi/open-in-new'
import { dashboardUrl } from '~/utils/urlUtils'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import MdiReload from '~icons/mdi/reload'
import DownIcon from '~icons/ic/round-keyboard-arrow-down'
import ContentCopyIcon from '~icons/mdi/content-copy'
import MdiXmlIcon from '~icons/mdi/xml'
const toast = useToast()
interface ShareBase {
uuid?: string
url?: string
role?: string
}
enum ShareBaseRole {
Editor = 'editor',
Viewer = 'viewer',
}
const { $api, $e } = useNuxtApp()
let base = $ref<null | ShareBase>(null)
const showEditBaseDropdown = $ref(false)
const { project } = useProject()
const { copy } = useClipboard()
const url = $computed(() => (base && base.uuid ? `${dashboardUrl()}#/nc/base/${base.uuid}` : null))
const loadBase = async () => {
try {
if (!project.value.id) return
const res = await $api.project.sharedBaseGet(project.value.id)
base = {
uuid: res.uuid,
url: res.url,
role: res.roles,
}
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const createShareBase = async (role = ShareBaseRole.Viewer) => {
try {
if (!project.value.id) return
const res = await $api.project.sharedBaseUpdate(project.value.id, {
roles: role,
})
base = res || {}
base.role = role
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:enable', { role })
}
const disableSharedBase = async () => {
try {
if (!project.value.id) return
await $api.project.sharedBaseDisable(project.value.id)
base = null
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:disable')
}
const recreate = async () => {
try {
if (!project.value.id) return
const sharedBase = await $api.project.sharedBaseCreate(project.value.id, {
roles: base?.role || ShareBaseRole.Viewer,
})
const newBase = sharedBase || {}
base = { ...newBase, role: base?.role }
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
$e('a:shared-base:recreate')
}
const copyUrl = async () => {
if (!url) return
copy(url)
toast.success('Copied shareable base url to clipboard!')
$e('c:shared-base:copy-url')
}
const navigateToSharedBase = () => {
if (!url) return
window.open(url, '_blank')
$e('c:shared-base:open-url')
}
const generateEmbeddableIframe = () => {
if (!url) return
copy(`<iframe
class="nc-embed"
src="${url}?embed"
frameborder="0"
width="100%"
height="700"
style="background: transparent; border: 1px solid #ddd"></iframe>`)
toast.success('Copied embeddable html code!')
$e('c:shared-base:copy-embed-frame')
}
onMounted(() => {
if (!base) {
loadBase()
}
})
</script>
<template>
<div class="flex flex-col w-full">
<div class="flex flex-row items-center space-x-0.5 pl-2 h-[0.8rem]">
<OpenInNewIcon />
<div class="text-xs">Shared Base Link</div>
</div>
<div v-if="base?.uuid" class="flex flex-row mt-2 bg-red-50 py-4 mx-1 px-2 items-center rounded-sm w-full justify-between">
<span class="flex text-xs overflow-x-hidden overflow-ellipsis text-gray-700 pl-2">{{ url }}</span>
<div class="flex border-l-1 pt-1 pl-1">
<a-tooltip placement="bottom">
<template #title>
<span>Reload</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="recreate">
<template #icon>
<MdiReload class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Copy URL</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="copyUrl">
<template #icon>
<ContentCopyIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Open new tab</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="navigateToSharedBase">
<template #icon>
<OpenInNewIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>
<span>Copy embeddable HTML code</span>
</template>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5 h-[1rem]" @click="generateEmbeddableIframe">
<template #icon>
<MdiXmlIcon class="flex mx-auto text-gray-600" />
</template>
</a-button>
</a-tooltip>
</div>
</div>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">Generate publicly shareable readonly base</div>
<div class="mt-4 flex flex-row justify-between mx-1">
<a-dropdown v-model="showEditBaseDropdown" class="flex">
<a-button>
<div class="flex flex-row items-center space-x-2">
<div v-if="base?.uuid">Anyone with the link</div>
<div v-else>Disable shared base</div>
<DownIcon class="h-[1rem]" />
</div>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item>
<div v-if="base?.uuid" @click="disableSharedBase">Disable shared base</div>
<div v-else @click="createShareBase(ShareBaseRole.Viewer)">Anyone with the link</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-select v-if="base?.uuid" v-model:value="base.role" class="flex">
<template #suffixIcon>
<div class="flex flex-row">
<DownIcon class="text-black -mt-0.5 h-[1rem]" />
</div>
</template>
<a-select-option
v-for="(role, index) in [ShareBaseRole.Editor, ShareBaseRole.Viewer]"
:key="index"
:value="role"
dropdown-class-name="capitalize"
@click="createShareBase(role)"
>
<div class="w-full px-2 capitalize">
{{ role }}
</div>
</a-select-option>
</a-select>
</div>
</div>
</template>
<style scoped></style>

238
packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue

@ -0,0 +1,238 @@
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import { Form } from 'ant-design-vue'
import { useClipboard } from '@vueuse/core'
import ShareBase from './ShareBase.vue'
import SendIcon from '~icons/material-symbols/send-outline'
import CloseIcon from '~icons/material-symbols/close-rounded'
import MidAccountIcon from '~icons/mdi/account-outline'
import ContentCopyIcon from '~icons/mdi/content-copy'
import type { User } from '~/lib/types'
import { ProjectRole } from '~/lib/enums'
import { projectRoleTagColors } from '~/utils/userUtils'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { isEmail } from '~/utils/validation'
interface Props {
show: boolean
selectedUser?: User
}
interface Users {
emails?: string
role: ProjectRole
invitationToken?: string
}
const { show, selectedUser } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const toast = useToast()
const { project } = useProject()
const { $api, $e } = useNuxtApp()
const { copy } = useClipboard()
const usersData = $ref<Users>({ emails: undefined, role: ProjectRole.Guest, invitationToken: undefined })
const formRef = ref()
const useForm = Form.useForm
const validators = computed(() => {
return {
emails: [
{
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => {
if (value.length === 0) {
callback('Email is required')
return
}
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e))
if (invalidEmails.length > 0) {
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `)
} else {
callback()
}
},
},
],
}
})
const { validateInfos } = useForm(usersData, validators)
onMounted(() => {
if (!usersData.emails && selectedUser?.email) {
usersData.emails = selectedUser.email
usersData.role = selectedUser.roles
}
})
const saveUser = async () => {
$e('a:user:invite', { role: usersData.role })
if (!project.value.id) return
await formRef.value?.validateFields()
try {
if (selectedUser?.id) {
await $api.auth.projectUserUpdate(project.value.id, selectedUser.id, {
roles: usersData.role,
email: selectedUser.email,
project_id: project.value.id,
projectName: project.value.title,
})
emit('reload')
emit('closed')
} else {
const res = await $api.auth.projectUserAdd(project.value.id, {
roles: usersData.role,
email: usersData.emails,
project_id: project.value.id,
projectName: project.value.title,
})
usersData.invitationToken = res.invite_token
}
toast.success('Successfully updated the user details')
} catch (e: any) {
console.error(e)
toast.error(await extractSdkResponseErrorMsg(e))
}
}
const inviteUrl = $computed(() =>
usersData.invitationToken
? `${location.origin}${location.pathname}#/user/authentication/signup/${usersData.invitationToken}`
: null,
)
const copyUrl = async () => {
if (!inviteUrl) return
copy(inviteUrl)
toast.success('Copied shareable base url to clipboard!')
$e('c:shared-base:copy-url')
}
const clickInviteMore = () => {
$e('c:user:invite-more')
usersData.invitationToken = undefined
usersData.role = ProjectRole.Guest
usersData.emails = undefined
}
</script>
<template>
<a-modal :footer="null" centered :visible="show" :closable="false" width="max(50vw, 44rem)" @cancel="emit('closed')">
<div class="flex flex-col">
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full">
<a-typography-title class="select-none" :level="4"> Share: {{ project.title }} </a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<CloseIcon class="flex mx-auto" />
</template>
</a-button>
</div>
<div class="px-2 mt-1.5">
<template v-if="usersData.invitationToken">
<div class="flex flex-col mt-1 border-b-1 pb-5">
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]">
<MidAccountIcon />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
<a-alert class="mt-1" type="success" show-icon>
<template #message>
<div class="flex flex-row w-full justify-between items-center">
<div class="flex pl-2 text-green-700">
{{ inviteUrl }}
</div>
<a-button type="text" class="!rounded-md mr-1" @click="copyUrl">
<template #icon>
<ContentCopyIcon class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
</template>
</a-alert>
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2">
Looks like you have not configured mailer yet! Please copy above invite link and send it to
{{ usersData.invitationToken && usersData.emails }}
</div>
<div class="flex flex-row justify-start mt-4 ml-2">
<a-button size="small" outlined @click="clickInviteMore">
<div class="flex flex-row justify-center items-center space-x-0.5">
<SendIcon class="flex mx-auto text-gray-600 h-[0.8rem]" />
<div class="text-xs text-gray-600">Invite more</div>
</div>
</a-button>
</div>
</div>
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MidAccountIcon />
<div class="text-xs ml-0.5 mt-0.5">{{ selectedUser ? 'Edit User' : 'Invite Team' }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
<a-form
ref="formRef"
:validate-on-rule-change="false"
:model="usersData"
validate-trigger="onBlur"
@finish="saveUser"
>
<div class="flex flex-row space-x-4">
<div class="flex flex-col w-3/4">
<a-form-item
v-bind="validateInfos.emails"
validate-trigger="onBlur"
name="emails"
:rules="[{ required: true, message: 'Please input email' }]"
>
<div class="ml-1 mb-1 text-xs text-gray-500">Email:</div>
<a-input
v-model:value="usersData.emails"
validate-trigger="onBlur"
placeholder="Email"
:disabled="!!selectedUser"
/>
</a-form-item>
</div>
<div class="flex flex-col w-1/4">
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]">
<div class="ml-1 mb-1 text-xs text-gray-500">Select User Role:</div>
<a-select v-model:value="usersData.role">
<a-select-option v-for="(role, index) in Object.keys(projectRoleTagColors)" :key="index" :value="role">
<div class="flex flex-row h-full justify-start items-center">
<div :class="`px-2 py-1 flex rounded-full text-xs bg-[${projectRoleTagColors[role]}]`">
{{ role }}
</div>
</div>
</a-select-option>
</a-select>
</a-form-item>
</div>
</div>
<div class="flex flex-row justify-center">
<a-button type="primary" html-type="submit">
<div v-if="selectedUser">Save</div>
<div v-else class="flex flex-row justify-center items-center space-x-1.5">
<SendIcon class="flex h-[0.8rem]" />
<div>Invite</div>
</div>
</a-button>
</div>
</a-form>
</div>
</div>
<div class="flex mt-4">
<ShareBase />
</div>
</div>
</div>
</a-modal>
</template>
<style scoped></style>

18
packages/nc-gui-v2/composables/useGlobalState.ts → packages/nc-gui-v2/composables/useGlobalState/index.ts

@ -1,6 +1,7 @@
import { breakpointsTailwind, usePreferredLanguages, useStorage } from '@vueuse/core'
import { useJwt } from '@vueuse/integrations/useJwt'
import type { JwtPayload } from 'jwt-decode'
import initialFeedBackForm from './initialFeedbackForm'
import { computed, ref, toRefs, useBreakpoints, useNuxtApp, useTimestamp, watch } from '#imports'
import type { Actions, Getters, GlobalState, StoredState, User } from '~/lib/types'
@ -23,6 +24,15 @@ const storageKey = 'nocodb-gui-v2'
* ```
*/
export const useGlobalState = (): GlobalState => {
const { $state } = useNuxtApp()
if ($state) {
console.warn(
'[useGlobalState] Global state is injected by state plugin. Manual initialization is unnecessary and should be avoided.',
)
return $state
}
/** get the preferred languages of a user, according to browser settings */
const preferredLanguages = $(usePreferredLanguages())
/** todo: reimplement; get the preferred dark mode setting, according to browser settings */
@ -67,7 +77,13 @@ export const useGlobalState = (): GlobalState => {
}, 'en' /** fallback locale */)
/** State */
const initialState: StoredState = { token: null, user: null, lang: preferredLanguage, darkMode: prefersDarkMode }
const initialState: StoredState = {
token: null,
user: null,
lang: preferredLanguage,
darkMode: prefersDarkMode,
feedbackForm: initialFeedBackForm,
}
/** saves a reactive state, any change to these values will write/delete to localStorage */
const storage = $(useStorage<StoredState>(storageKey, initialState))

5
packages/nc-gui-v2/composables/useGlobalState/initialFeedBackForm.ts

@ -0,0 +1,5 @@
export default {
url: 'https://docs.google.com/forms/d/e/1FAIpQLSeTlAfZjszgr53lArz3NvUEnJGOT9JtG9NAU5d0oQwunDS2Pw/viewform?embedded=true',
createdAt: new Date('2020-01-01T00:00:00.000Z').toISOString(),
isHidden: false,
}

7
packages/nc-gui-v2/lib/enums.ts

@ -4,6 +4,13 @@ export enum Role {
User = 'user',
}
export enum ProjectRole {
Owner = 'owner',
Editor = 'editor',
User = 'user',
Guest = 'guest',
}
export enum ClientType {
MYSQL = 'mysql2',
MSSQL = 'mssql',

8
packages/nc-gui-v2/lib/types.ts

@ -9,11 +9,19 @@ export interface User {
roles: Roles
}
export interface FeedbackForm {
url: string
createdAt: string
isHidden: boolean
lastFormPollDate?: string
}
export interface StoredState {
token: string | null
user: User | null
lang: string
darkMode: boolean
feedbackForm: FeedbackForm
}
export interface State extends StoredState {

38
packages/nc-gui-v2/package-lock.json generated

@ -26,7 +26,9 @@
"devDependencies": {
"@antfu/eslint-config": "^0.25.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",
"@iconify-json/lucide": "^1.1.36",
"@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/ri": "^1.1.3",
@ -10427,6 +10429,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/eva": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/eva/-/eva-1.1.2.tgz",
"integrity": "sha512-4n3sAzXYH4vX2ehi2+kMPP7VHM1TTZ2AKJyUrAogAdbEKWhHr891huFhOSZPRJ9F2/4D4Us8SjRxBHYyUvX80w==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/ic": {
"version": "1.1.7",
"dev": true,
@ -10435,6 +10446,15 @@
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/lucide": {
"version": "1.1.37",
"resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.1.37.tgz",
"integrity": "sha512-GfeDEy61ols35CYLfNxQWCIWdVTf/V0GI29D5IT74ToxcwqjtInQ6YVeNJCfKSGsTnnuyG+M6drd6YXLEfdjvQ==",
"dev": true,
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/material-symbols": {
"version": "1.1.8",
"dev": true,
@ -23062,6 +23082,15 @@
"@iconify/types": "*"
}
},
"@iconify-json/eva": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@iconify-json/eva/-/eva-1.1.2.tgz",
"integrity": "sha512-4n3sAzXYH4vX2ehi2+kMPP7VHM1TTZ2AKJyUrAogAdbEKWhHr891huFhOSZPRJ9F2/4D4Us8SjRxBHYyUvX80w==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/ic": {
"version": "1.1.7",
"dev": true,
@ -23069,6 +23098,15 @@
"@iconify/types": "*"
}
},
"@iconify-json/lucide": {
"version": "1.1.37",
"resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.1.37.tgz",
"integrity": "sha512-GfeDEy61ols35CYLfNxQWCIWdVTf/V0GI29D5IT74ToxcwqjtInQ6YVeNJCfKSGsTnnuyG+M6drd6YXLEfdjvQ==",
"dev": true,
"requires": {
"@iconify/types": "*"
}
},
"@iconify-json/material-symbols": {
"version": "1.1.8",
"dev": true,

2
packages/nc-gui-v2/package.json

@ -32,7 +32,9 @@
"devDependencies": {
"@antfu/eslint-config": "^0.25.2",
"@iconify-json/clarity": "^1.1.4",
"@iconify-json/eva": "^1.1.2",
"@iconify-json/ic": "^1.1.7",
"@iconify-json/lucide": "^1.1.36",
"@iconify-json/material-symbols": "^1.1.8",
"@iconify-json/mdi": "^1.1.25",
"@iconify-json/ri": "^1.1.3",

16
packages/nc-gui-v2/pages/nc/[projectId]/index.vue

@ -3,25 +3,15 @@ import useTabs from '~/composables/useTabs'
const route = useRoute()
const { loadProject, loadTables } = useProject(route.params.projectId as string)
const { clearTabs, addTab } = useTabs()
const { addTab } = useTabs()
const { $state } = useNuxtApp()
if (!route.params.type) {
addTab({ type: 'auth', title: 'Team & Auth' })
}
watch(
() => route.params.projectId,
async (newVal, oldVal) => {
if (newVal !== oldVal) {
clearTabs()
if (newVal) {
await loadProject(newVal as string)
await loadTables()
}
}
},
)
await loadProject(route.params.projectId as string)
await loadTables()
$state.sidebarOpen.value = true
</script>

8
packages/nc-gui-v2/pages/nc/[projectId]/index/index/auth.vue

@ -1,5 +1,7 @@
<script lang="ts" setup>
import Auth from '~~/components/tabs/Auth.vue'
</script>
<template>
<div>
<h2 class="text-3xl mt-3">Team & Auth</h2>
</div>
<Auth />
</template>

2
packages/nc-gui-v2/plugins/api.ts

@ -21,7 +21,7 @@ function addAxiosInterceptors(api: Api<any>, app: { $state: GlobalState }) {
api.instance.interceptors.request.use((config) => {
config.headers['xc-gui'] = 'true'
if (app.$state.token.value) config.headers['xc-auth'] = app.$state.token.value
if (app.$state?.token.value) config.headers['xc-auth'] = app.$state.token.value
if (!config.url?.endsWith('/user/me') && !config.url?.endsWith('/admin/roles')) {
// config.headers['xc-preview'] = store.state.users.previewAs

39
packages/nc-gui-v2/plugins/initializeFeedbackForm.ts

@ -0,0 +1,39 @@
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import { defineNuxtPlugin } from '#app'
const handleFeedbackForm = async () => {
let { feedbackForm: currentFeedbackForm } = $(useGlobalState())
if (!currentFeedbackForm) return
const { $api } = useNuxtApp()
const fetchFeedbackForm = async (now: Dayjs) => {
try {
const { data: feedbackForm } = await $api.instance.get('/api/v1/feedback_form')
const isFetchedFormDuplicate = currentFeedbackForm.url === feedbackForm.url
currentFeedbackForm = {
url: feedbackForm.url,
lastFormPollDate: now.toISOString(),
createdAt: feedbackForm.created_at,
isHidden: isFetchedFormDuplicate ? currentFeedbackForm.isHidden : false,
}
} catch (e) {
console.error(e)
}
}
const isFirstTimePolling = !currentFeedbackForm.lastFormPollDate
const now = dayjs()
const lastFormPolledDate = dayjs(currentFeedbackForm.lastFormPollDate)
if (isFirstTimePolling || dayjs.duration(now.diff(lastFormPolledDate)).days() > 0) {
await fetchFeedbackForm(now)
}
}
export default defineNuxtPlugin(async () => {
await handleFeedbackForm()
})

2
packages/nc-gui-v2/utils/dateTimeUtils.ts

@ -2,12 +2,14 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import duration from 'dayjs/plugin/duration'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
dayjs.extend(relativeTime)
dayjs.extend(customParseFormat)
dayjs.extend(duration)
export const timeAgo = (date: any) => {
return dayjs.utc(date).fromNow()

4
packages/nc-gui-v2/utils/urlUtils.ts

@ -17,6 +17,10 @@ export const replaceUrlsWithLink = (text: string): boolean | string => {
return found && out
}
export const dashboardUrl = () => {
return `${location.origin}${location.pathname || ''}`
}
// ref : https://stackoverflow.com/a/5717133
export const isValidURL = (str: string) => {
const pattern =

8
packages/nc-gui-v2/utils/userUtils.ts

@ -0,0 +1,8 @@
import { ProjectRole } from '~/lib/enums'
export const projectRoleTagColors = {
[ProjectRole.Owner]: '#cfdffe',
[ProjectRole.Editor]: '#c2f5e8',
[ProjectRole.User]: '#4caf50',
[ProjectRole.Guest]: '#9e9e9e',
}
Loading…
Cancel
Save