mirror of https://github.com/nocodb/nocodb
navi
2 years ago
committed by
GitHub
21 changed files with 1183 additions and 22 deletions
@ -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> |
||||
|
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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, |
||||
} |
@ -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> |
||||
|
@ -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() |
||||
}) |
Loading…
Reference in new issue