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