mirror of https://github.com/nocodb/nocodb
Muhammed Mustafa
2 years ago
11 changed files with 789 additions and 1 deletions
@ -0,0 +1,247 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useDebounce } from '@vueuse/core' |
||||||
|
import UsersModal from './userManagement/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 { copyTextToClipboard } from '~/utils/miscUtils' |
||||||
|
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 SendIcon from '~icons/material-symbols/send' |
||||||
|
import { User } from '~~/lib/types' |
||||||
|
import { useToast } from 'vue-toastification' |
||||||
|
const toast = useToast() |
||||||
|
|
||||||
|
const { $api, $e } = useNuxtApp() |
||||||
|
const { project } = useProject() |
||||||
|
|
||||||
|
let users = $ref<null | Array<User>>(null) |
||||||
|
let selectedUser = $ref<null | User>(null) |
||||||
|
let showUserModal = $ref(false) |
||||||
|
let showUserDeleteModal = $ref(false) |
||||||
|
|
||||||
|
let totalRows = $ref(0) |
||||||
|
const currentPage = $ref(1) |
||||||
|
const currentLimit = $ref(10) |
||||||
|
const searchText = ref<string>('') |
||||||
|
const debouncedSearchText = useDebounce(searchText, 300) |
||||||
|
|
||||||
|
const loadUsers = async (page = currentPage, limit = currentLimit) => { |
||||||
|
try { |
||||||
|
console.log('loadUsers', page, limit) |
||||||
|
if (!project.value?.id) return |
||||||
|
|
||||||
|
const response = await $api.auth.projectUserList(project.value?.id, <any> { |
||||||
|
query: { |
||||||
|
limit, |
||||||
|
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||||
|
query: searchText.value, |
||||||
|
}, |
||||||
|
}) |
||||||
|
console.log('userData', response) |
||||||
|
if (!response.users) return |
||||||
|
|
||||||
|
totalRows = response.users.pageInfo.totalRows ?? 0 |
||||||
|
users = response.users.list as Array<User> |
||||||
|
} catch (e) { |
||||||
|
console.log(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const inviteUser = async (user) => { |
||||||
|
try { |
||||||
|
if (!project.value?.id) return |
||||||
|
|
||||||
|
await $api.auth.projectUserAdd(project.id, user); |
||||||
|
toast.success('Successfully added user to project'); |
||||||
|
await loadUsers(); |
||||||
|
} catch (e) { |
||||||
|
toast.error(e.response.data.msg); |
||||||
|
} |
||||||
|
|
||||||
|
$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(); |
||||||
|
} catch (e) { |
||||||
|
toast.error(e.response.data.msg); |
||||||
|
} |
||||||
|
|
||||||
|
$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) { |
||||||
|
toast.error(e.response.data.msg); |
||||||
|
} |
||||||
|
|
||||||
|
$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}`; |
||||||
|
|
||||||
|
copyTextToClipboard(getInviteUrl(user.invite_token) ); |
||||||
|
toast.success('Invite url copied to clipboard'); |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(async () => { |
||||||
|
if (!users) { |
||||||
|
await loadUsers() |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
watch( |
||||||
|
() => debouncedSearchText.value, |
||||||
|
() => loadUsers(), |
||||||
|
) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col w-full px-6"> |
||||||
|
<UsersModal :key="showUserModal" :show="showUserModal" :selected-user="selectedUser" @closed="showUserModal = false" /> |
||||||
|
<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-6"> |
||||||
|
<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-button v-if="user.project_id" type="text" class="!rounded-md" @click="onEdit(user)"> |
||||||
|
<template #icon> |
||||||
|
<MdiEditIcon height="1rem" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
<a-button type="text" v-if="!user.project_id" class="!rounded-md " @click="inviteUser(user)"> |
||||||
|
<template #icon> |
||||||
|
<MdiPlusIcon height="1.1rem" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
<a-button v-else type="text" class="!rounded-md" @click="onDelete(user)"> |
||||||
|
<template #icon> |
||||||
|
<MdiDeleteOutlineIcon height="1.1rem" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
<a-button type="text" class="!rounded-md" @click="resendInvite(user)"> |
||||||
|
<template #icon> |
||||||
|
<MdiEmailSendIcon height="1.1rem" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
<a-button type="text" class="!rounded-md" @click="copyInviteUrl(user)"> |
||||||
|
<template #icon> |
||||||
|
<MdiContentCopyIcon height="1.1rem" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<a-pagination |
||||||
|
hideOnSinglePage |
||||||
|
v-model:current="currentPage" |
||||||
|
class="mt-4" |
||||||
|
:page-size="currentLimit" |
||||||
|
:total="totalRows" |
||||||
|
show-less-items |
||||||
|
@change="loadUsers" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
.users-table { |
||||||
|
/* equally spaced columns in table */ |
||||||
|
table-layout: fixed; |
||||||
|
|
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,209 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useToast } from 'vue-toastification' |
||||||
|
import OpenInNewIcon from '~icons/mdi/open-in-new' |
||||||
|
import { dashboardUrl } from '~/utils/urlUtils' |
||||||
|
|
||||||
|
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' |
||||||
|
import { copyTextToClipboard } from '~/utils/miscUtils' |
||||||
|
const toast = useToast() |
||||||
|
|
||||||
|
interface ShareBase { |
||||||
|
uuid?: string |
||||||
|
url?: string |
||||||
|
role?: string |
||||||
|
} |
||||||
|
|
||||||
|
enum Role { |
||||||
|
Owner = 'owner', |
||||||
|
Editor = 'editor', |
||||||
|
User = 'user', |
||||||
|
Guest = 'guest', |
||||||
|
Viewer = 'viewer', |
||||||
|
} |
||||||
|
|
||||||
|
const { $api, $e } = useNuxtApp() |
||||||
|
let base = $ref<null | ShareBase>(null) |
||||||
|
const showEditBaseDropdown = $ref(false) |
||||||
|
const { project } = useProject() |
||||||
|
|
||||||
|
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) { |
||||||
|
console.error(e) |
||||||
|
toast.error('Something went wrong') |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const createShareBase = async (role = Role.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) { |
||||||
|
console.error(e) |
||||||
|
toast.error('Something went wrong') |
||||||
|
} |
||||||
|
$e('a:shared-base:enable', { role }) |
||||||
|
} |
||||||
|
|
||||||
|
const disableSharedBase = async () => { |
||||||
|
try { |
||||||
|
if (!project.value.id) return |
||||||
|
|
||||||
|
await $api.project.sharedBaseDisable(project.value.id) |
||||||
|
base = {} |
||||||
|
} catch (e) { |
||||||
|
toast.error(e.message) |
||||||
|
} |
||||||
|
|
||||||
|
$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 || 'viewer', |
||||||
|
}) |
||||||
|
const newBase = sharedBase || {} |
||||||
|
base = { ...newBase, role: base?.role } |
||||||
|
} catch (e) { |
||||||
|
toast.error(e.message) |
||||||
|
} |
||||||
|
|
||||||
|
$e('a:shared-base:recreate') |
||||||
|
} |
||||||
|
|
||||||
|
const copyUrl = async () => { |
||||||
|
if (!url) return |
||||||
|
|
||||||
|
copyTextToClipboard(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 |
||||||
|
|
||||||
|
copyTextToClipboard(`<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"> |
||||||
|
<div class="flex flex-row items-center space-x-0.5 pl-1.5"> |
||||||
|
<OpenInNewIcon height="0.8rem" class="text-gray-500" /> |
||||||
|
<div class="text-gray-500 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"> |
||||||
|
<span class="text-xs overflow-x-hidden overflow-ellipsis text-gray-700">{{ url }}</span> |
||||||
|
<a-divider class="flex" type="vertical" /> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="recreate"> |
||||||
|
<template #icon> |
||||||
|
<MdiReload height="1rem" class="flex mx-auto text-gray-600" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="copyUrl"> |
||||||
|
<template #icon> |
||||||
|
<ContentCopyIcon height="1rem" class="flex mx-auto text-gray-600" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="navigateToSharedBase"> |
||||||
|
<template #icon> |
||||||
|
<OpenInNewIcon height="1rem" class="flex mx-auto text-gray-600" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="generateEmbeddableIframe"> |
||||||
|
<template #icon> |
||||||
|
<MdiXmlIcon height="1rem" class="flex mx-auto text-gray-600" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
<div class="flex text-xs text-gray-400 mt-2 justify-start ml-2"> |
||||||
|
This link can be used to signup by anyone under this project |
||||||
|
</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 height="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(Role.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"> |
||||||
|
<a-select-option |
||||||
|
v-for="(role, index) in [Role.Editor, Role.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,215 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useToast } from 'vue-toastification' |
||||||
|
import { Form } from 'ant-design-vue' |
||||||
|
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 { copyTextToClipboard } from '~/utils/miscUtils' |
||||||
|
import { extractSdkResponseErrorMsg } from '~~/utils/errorUtils' |
||||||
|
import { isEmail } from '~~/utils/validation' |
||||||
|
const { show, selectedUser } = defineProps<Props>() |
||||||
|
const emits = defineEmits(['closed']) |
||||||
|
const toast = useToast() |
||||||
|
interface Props { |
||||||
|
show: boolean |
||||||
|
selectedUser?: User |
||||||
|
} |
||||||
|
interface Users { |
||||||
|
emails: string |
||||||
|
role: ProjectRole |
||||||
|
invitationToken?: string |
||||||
|
} |
||||||
|
|
||||||
|
const { project } = useProject() |
||||||
|
const { $api, $e } = useNuxtApp() |
||||||
|
|
||||||
|
const usersData = $ref<Users>({ emails: '', role: ProjectRole.Guest, invitationToken: undefined }) |
||||||
|
let isFirstRender = $ref(true) |
||||||
|
const inviteToken = $ref(null) |
||||||
|
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 (isFirstRender && selectedUser) { |
||||||
|
usersData.emails = selectedUser.email |
||||||
|
usersData.role = selectedUser.roles |
||||||
|
} |
||||||
|
if (isFirstRender) isFirstRender = false |
||||||
|
}) |
||||||
|
|
||||||
|
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: selectedUser.roles, |
||||||
|
email: selectedUser.email, |
||||||
|
project_id: project.value.id, |
||||||
|
projectName: project.value.title, |
||||||
|
}) |
||||||
|
} 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) { |
||||||
|
toast.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const inviteUrl = $computed(() => |
||||||
|
inviteToken ? `${location.origin}${location.pathname}#/user/authentication/signup/${inviteToken}` : null, |
||||||
|
) |
||||||
|
|
||||||
|
const copyUrl = async () => { |
||||||
|
if (!inviteUrl) return |
||||||
|
|
||||||
|
copyTextToClipboard(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 = '' |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-modal :footer="null" centered :visible="show" :closable="false" width="max(50vw, 44rem)" @cancel="emits('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" type="secondary" :level="5"> Share: {{ project.title }} </a-typography-title> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emits('closed')"> |
||||||
|
<template #icon> |
||||||
|
<CloseIcon class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="px-3"> |
||||||
|
<template v-if="usersData.invitationToken"> |
||||||
|
<div class="flex flex-col mt-1 border-b-1 pb-5"> |
||||||
|
<div class="flex flex-row items-center"> |
||||||
|
<MidAccountIcon height="1.1rem" class="text-gray-500" /> |
||||||
|
<div class="text-gray-500 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 }} http://localhost:3001/nc/p_60c3wrrzv563zq#/nc/base/2d6f72d7-b16a-40f2-951c-00b1b90f3920 |
||||||
|
</div> |
||||||
|
<a-button type="text" class="!rounded-md mr-1" @click="copyUrl"> |
||||||
|
<template #icon> |
||||||
|
<ContentCopyIcon height="1rem" class="flex mx-auto text-green-700" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-alert> |
||||||
|
<div class="flex text-xs text-gray-400 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-1"> |
||||||
|
<a-button size="small" outlined @click="clickInviteMore"> |
||||||
|
<div class="flex flex-row justify-center items-center space-x-0.5"> |
||||||
|
<SendIcon height="0.8rem" class="flex mx-auto text-gray-600" /> |
||||||
|
<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"> |
||||||
|
<MidAccountIcon height="1rem" class="text-gray-500" /> |
||||||
|
<div class="text-gray-500 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" :model="usersData" @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" |
||||||
|
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" 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">Submit</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,56 @@ |
|||||||
|
export const copyTextToClipboard = (text: string) => { |
||||||
|
const textArea = document.createElement('textarea') |
||||||
|
|
||||||
|
//
|
||||||
|
// *** This styling is an extra step which is likely not required. ***
|
||||||
|
//
|
||||||
|
// Why is it here? To ensure:
|
||||||
|
// 1. the element is able to have focus and selection.
|
||||||
|
// 2. if element was to flash render it has minimal visual impact.
|
||||||
|
// 3. less flakyness with selection and copying which **might** occur if
|
||||||
|
// the textarea element is not visible.
|
||||||
|
//
|
||||||
|
// The likelihood is the element won't even render, not even a
|
||||||
|
// flash, so some of these are just precautions. However in
|
||||||
|
// Internet Explorer the element is visible whilst the popup
|
||||||
|
// box asking the user for permission for the web page to
|
||||||
|
// copy to the clipboard.
|
||||||
|
//
|
||||||
|
|
||||||
|
// Place in top-left corner of screen regardless of scroll position.
|
||||||
|
textArea.style.position = 'fixed' |
||||||
|
textArea.style.top = '0' |
||||||
|
textArea.style.left = '0' |
||||||
|
|
||||||
|
// Ensure it has a small width and height. Setting to 1px / 1em
|
||||||
|
// doesn't work as this gives a negative w/h on some browsers.
|
||||||
|
textArea.style.width = '2em' |
||||||
|
textArea.style.height = '2em' |
||||||
|
|
||||||
|
// We don't need padding, reducing the size if it does flash render.
|
||||||
|
textArea.style.padding = '0' |
||||||
|
|
||||||
|
// Clean up any borders.
|
||||||
|
textArea.style.border = 'none' |
||||||
|
textArea.style.outline = 'none' |
||||||
|
textArea.style.boxShadow = 'none' |
||||||
|
|
||||||
|
// Avoid flash of white box if rendered for any reason.
|
||||||
|
textArea.style.background = 'transparent' |
||||||
|
|
||||||
|
textArea.addEventListener('focusin', (e) => e.stopPropagation()) |
||||||
|
|
||||||
|
textArea.value = text |
||||||
|
|
||||||
|
document.body.appendChild(textArea) |
||||||
|
textArea.focus() |
||||||
|
textArea.select() |
||||||
|
|
||||||
|
try { |
||||||
|
document.execCommand('copy') |
||||||
|
} catch (err) { |
||||||
|
console.log('Oops, unable to copy') |
||||||
|
} |
||||||
|
|
||||||
|
document.body.removeChild(textArea) |
||||||
|
} |
Loading…
Reference in new issue