Merge pull request #325 from shariquerik/invite-members

feat: Invite Members
This commit is contained in:
Shariq Ansari 2024-09-03 15:59:40 +05:30 committed by GitHub
commit 1040c7eff2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 526 additions and 9 deletions

View File

@ -1,7 +1,7 @@
from bs4 import BeautifulSoup
import frappe
from frappe.translate import get_all_translations
from frappe.utils import cstr
from frappe.utils import validate_email_address, split_emails, cstr
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@ -66,4 +66,44 @@ def check_app_permission():
if any(role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles):
return True
return False
return False
@frappe.whitelist(allow_guest=True)
def accept_invitation(key: str = None):
if not key:
frappe.throw("Invalid or expired key")
result = frappe.db.get_all("CRM Invitation", filters={"key": key}, pluck="name")
if not result:
frappe.throw("Invalid or expired key")
invitation = frappe.get_doc("CRM Invitation", result[0])
invitation.accept()
invitation.reload()
if invitation.status == "Accepted":
frappe.local.login_manager.login_as(invitation.email)
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = "/crm"
@frappe.whitelist()
def invite_by_email(emails: str, role: str):
if not emails:
return
email_string = validate_email_address(emails, throw=False)
email_list = split_emails(email_string)
if not email_list:
return
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
existing_invites = frappe.db.get_all(
"CRM Invitation",
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
pluck="email",
)
to_invite = list(set(email_list) - set(existing_members) - set(existing_invites))
for email in to_invite:
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)

View File

@ -0,0 +1,12 @@
// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("CRM Invitation", {
refresh(frm) {
if (frm.doc.status != "Accepted") {
frm.add_custom_button(__("Accept Invitation"), () => {
return frm.call("accept_invitation");
});
}
},
});

View File

@ -0,0 +1,112 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-09-03 12:19:18.933810",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"email",
"role",
"key",
"invited_by",
"column_break_dsuz",
"status",
"email_sent_at",
"accepted_at"
],
"fields": [
{
"fieldname": "email",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Email",
"reqd": 1
},
{
"fieldname": "role",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Role",
"options": "\nSales User\nSales Manager",
"reqd": 1
},
{
"fieldname": "key",
"fieldtype": "Data",
"label": "Key"
},
{
"fieldname": "invited_by",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Invited By",
"options": "User"
},
{
"fieldname": "column_break_dsuz",
"fieldtype": "Column Break"
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "\nPending\nAccepted\nExpired"
},
{
"fieldname": "email_sent_at",
"fieldtype": "Datetime",
"label": "Email Sent At"
},
{
"fieldname": "accepted_at",
"fieldtype": "Datetime",
"label": "Accepted At"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-09-03 14:59:29.450018",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Invitation",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,79 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class CRMInvitation(Document):
def before_insert(self):
frappe.utils.validate_email_address(self.email, True)
self.key = frappe.generate_hash(length=12)
self.invited_by = frappe.session.user
self.status = "Pending"
def after_insert(self):
self.invite_via_email()
def invite_via_email(self):
invite_link = frappe.utils.get_url(f"/api/method/crm.api.accept_invitation?key={self.key}")
if frappe.local.dev_server:
print(f"Invite link for {self.email}: {invite_link}")
title = f"Frappe CRM"
template = "crm_invitation"
frappe.sendmail(
recipients=self.email,
subject=f"You have been invited to join {title}",
template=template,
args={"title": title, "invite_link": invite_link},
now=True,
)
self.db_set("email_sent_at", frappe.utils.now())
@frappe.whitelist()
def accept_invitation(self):
frappe.only_for("System Manager")
self.accept()
def accept(self):
if self.status == "Expired":
frappe.throw("Invalid or expired key")
user = self.create_user_if_not_exists()
user.append_roles(self.role)
user.save(ignore_permissions=True)
self.status = "Accepted"
self.accepted_at = frappe.utils.now()
self.save(ignore_permissions=True)
def create_user_if_not_exists(self):
if not frappe.db.exists("User", self.email):
first_name = self.email.split("@")[0].title()
user = frappe.get_doc(
doctype="User",
user_type="System User",
email=self.email,
send_welcome_email=0,
first_name=first_name,
).insert(ignore_permissions=True)
else:
user = frappe.get_doc("User", self.email)
return user
def expire_invitations():
"""expire invitations after 3 days"""
from frappe.utils import add_days, now
days = 3
invitations_to_expire = frappe.db.get_all(
"CRM Invitation", filters={"status": "Pending", "creation": ["<", add_days(now(), -days)]}
)
for invitation in invitations_to_expire:
invitation = frappe.get_doc("CRM Invitation", invitation.name)
invitation.status = "Expired"
invitation.save(ignore_permissions=True)

View File

@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCRMInvitation(FrappeTestCase):
pass

View File

@ -0,0 +1,4 @@
<h2>You have been invited to join Frappe CRM</h2>
<p>
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
</p>

View File

@ -0,0 +1,120 @@
<template>
<div>
<div
class="flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 text-base border border-gray-300 bg-white hover:border-gray-400 focus:border-gray-500 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors w-full"
@click="setFocus"
>
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<TextInput
ref="search"
class="w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
type="text"
v-model="query"
placeholder="example@email.com"
@keydown.enter.capture.stop="addValue()"
@keydown.delete.capture.stop="removeLastValue"
/>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
</div>
</template>
<script setup>
import { TextInput } from 'frappe-ui'
import { ref, nextTick } from 'vue'
const props = defineProps({
validate: {
type: Function,
default: null,
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const query = ref('')
const addValue = () => {
let value = query.value
error.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
}
}
})
!error.value && (query.value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -0,0 +1,126 @@
<template>
<div class="flex h-full flex-col gap-8 p-8">
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
{{ __('Send Invites To') }}
</h2>
<div class="flex-1 overflow-y-auto">
<MultiValueInput
v-model="invitees"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
<FormControl
type="select"
class="mt-4"
v-model="role"
variant="outline"
:label="__('Invite as')"
:options="[
{ label: __('Regular Access'), value: 'Sales User' },
{ label: __('Manager Access'), value: 'Sales Manager' },
]"
:description="description"
/>
<ErrorMessage class="mt-2" v-if="error" :message="error" />
<template v-if="pendingInvitations.data?.length && !invitees.length">
<div
class="mt-4 flex items-center justify-between border-b py-2 text-base text-gray-600"
>
<div class="w-4/5">{{ __('Pending Invites') }}</div>
</div>
<ul class="divide-y overflow-auto">
<li
class="flex items-center justify-between py-2"
v-for="user in pendingInvitations.data"
:key="user.name"
>
<div class="w-4/5 text-base">
<span class="text-gray-900">
{{ user.email }}
</span>
<span class="text-gray-600"> ({{ roleMap[user.role] }}) </span>
</div>
<div>
<Tooltip text="Delete Invitation">
<Button
icon="x"
:loading="
pendingInvitations.delete.loading &&
pendingInvitations.delete.params.name === user.name
"
@click="pendingInvitations.delete.submit(user.name)"
/>
</Tooltip>
</div>
</li>
</ul>
</template>
</div>
<div class="flex flex-row-reverse">
<Button
:label="__('Send Invites')"
variant="solid"
@click="inviteByEmail.submit()"
:loading="inviteByEmail.loading"
/>
</div>
</div>
</template>
<script setup>
import MultiValueInput from '@/components/Controls/MultiValueInput.vue'
import { validateEmail, convertArrayToString } from '@/utils'
import {
createListResource,
createResource,
FormControl,
Tooltip,
} from 'frappe-ui'
import { ref, computed } from 'vue'
const invitees = ref([])
const role = ref('Sales User')
const error = ref(null)
const description = computed(() => {
return {
'Sales Manager':
'Can manage and invite new members, and create public & private views (reports).',
'Sales User':
'Can work with leads and deals and create private views (reports).',
}[role.value]
})
const roleMap = {
'Sales User': __('Regular Access'),
'Sales Manager': __('Manager Access'),
}
const inviteByEmail = createResource({
url: 'crm.api.invite_by_email',
makeParams() {
return {
emails: convertArrayToString(invitees.value),
role: role.value,
}
},
onSuccess() {
invitees.value = []
role.value = 'Sales User'
error.value = null
pendingInvitations.reload()
},
onError(error) {
error.value = error
},
})
const pendingInvitations = createListResource({
type: 'list',
doctype: 'CRM Invitation',
filters: { status: 'Pending' },
fields: ['name', 'email', 'role'],
auto: true,
})
</script>

View File

@ -40,6 +40,7 @@
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
@ -53,26 +54,31 @@ const show = defineModel()
const tabs = computed(() => {
let _tabs = [
{
label: 'Settings',
label: __('Settings'),
hideLabel: true,
items: [
{
label: 'Profile',
label: __('Profile'),
icon: ContactsIcon,
component: markRaw(ProfileSettings),
},
{
label: __('Invite Members'),
icon: 'user-plus',
component: markRaw(InviteMemberPage),
},
],
},
{
label: 'Integrations',
label: __('Integrations'),
items: [
{
label: 'Twilio',
label: __('Twilio'),
icon: PhoneIcon,
component: markRaw(TwilioSettings),
},
{
label: 'WhatsApp',
label: __('WhatsApp'),
icon: WhatsAppIcon,
component: markRaw(WhatsAppSettings),
condition: () => isWhatsappInstalled.value,

View File

@ -21,7 +21,12 @@
</span>
</slot>
</Tooltip>
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
<Tooltip
:text="label"
placement="right"
:disabled="isCollapsed"
:hoverDelay="1.5"
>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
@ -50,7 +55,7 @@ const route = useRoute()
const props = defineProps({
icon: {
type: Object,
type: [Object, String],
},
label: {
type: String,

View File

@ -216,3 +216,7 @@ export function isEmoji(str) {
export function isTouchScreenDevice() {
return "ontouchstart" in document.documentElement;
}
export function convertArrayToString(array) {
return array.map((item) => item).join(',')
}