1<template>
2 <SettingsLayoutBase
3 :title="__('Invite Agents')"
4 :description="__('Easily invite new agents, managers, or admins.')"
5 >
6 <template #content>
7 <form @submit.prevent="onSubmit" class="flex flex-col gap-5">
8 <FormControl
9 type="textarea"
10 :required="true"
11 :label="__('Invite by email')"
12 placeholder="user1@example.com, user2@example.com, ..."
13 v-model="emails"
14 :debounce="100"
15 :description="__('Comma separated emails to invite.')"
16 />
17 <div class="space-y-1.5">
18 <label class="block text-base text-ink-gray-5">
19 {{ __("Role") }}
20 <span class="text-ink-red-6 select-none" aria-hidden="true">*</span>
21 </label>
22 <Select :options="roleOptions" v-model="role" required class="w-full">
23 <template #suffix>
24 <LucideChevronDown
25 class="size-4 shrink-0 text-ink-gray-4 ms-auto"
26 />
27 </template>
28 </Select>
29 <p class="text-p-xs text-ink-gray-5">{{ roleDescription }}</p>
30 </div>
31 <Button
32 type="submit"
33 variant="solid"
34 class="w-fit mt-1"
35 :disabled="cancelInviteResource.loading"
36 :loading="inviteByEmailResource.loading"
37 >{{ __("Send Invites") }}</Button
38 >
39 </form>
40 <template v-if="pendingInvitesResource.data?.length">
41 <h2 class="mt-8 text-base-semibold">
42 {{ __("Pending Invites") }}
43 </h2>
44 <ul class="flex flex-col gap-[0.375rem] mt-3">
45 <li
46 v-for="invite in pendingInvitesResource.data"
47 :key="invite.name"
48 class="flex items-center justify-between px-3 py-1 rounded-6 bg-surface-gray-2"
49 >
50 <div class="text-base">
51 <span class="text-ink-gray-8">
52 {{ invite.email }}
53 </span>
54 <span class="text-ink-gray-5">
55 ({{ rolesToLabel(invite.roles) }})
56 </span>
57 </div>
58 <div>
59 <Tooltip
60 :disabled="
61 inviteByEmailResource.loading || cancelInviteResource.loading
62 "
63 :text="__('Cancel Invitation')"
64 >
65 <div>
66 <Button
67 icon="lucide-x"
68 variant="ghost"
69 :loading="
70 cancelInviteResource.loading &&
71 invite.name === cancelInviteResource.params.name
72 "
73 @click="
74 () => {
75 if (
76 inviteByEmailResource.loading ||
77 cancelInviteResource.loading
78 ) {
79 return;
80 }
81 cancelInviteResource.submit({
82 name: invite.name,
83 app_name: 'opendesk',
84 });
85 }
86 "
87 v-bind="{
88 ...((cancelInviteResource.loading &&
89 cancelInviteResource.params.name !== invite.name) ||
90 inviteByEmailResource.loading
91 ? { 'aria-disabled': true, class: 'opacity-25' }
92 : {}),
93 }"
94 />
95 </div>
96 </Tooltip>
97 </div>
98 </li>
99 </ul>
100 </template>
101 </template>
102 </SettingsLayoutBase>
103</template>
104
105<script setup lang="ts">
106import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
107import { useAuthStore } from "@/stores/auth";
108import { capture } from "@/telemetry";
109import { __ } from "@/translation";
110import { handleInviteUserSuccess } from "@/utils";
111import {
112 Button,
113 FormControl,
114 Select,
115 Tooltip,
116 createResource,
117 toast,
118} from "frappe-ui";
119import { useOnboarding } from "@framework/ui";
120import { computed, ref } from "vue";
121import LucideChevronDown from "~icons/lucide/chevron-down";
122
123const authStore = useAuthStore();
124const { isAdmin, isManager } = authStore;
125
126// @ts-expect-error
127const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};
128
129const emails = ref("");
130
131type Role = "Agent" | "Agent Manager" | "System Manager";
132type RoleOption = {
133 label: string;
134 value: Role;
135 description: string;
136};
137
138const roleToLabel = (role: Role) => {
139 switch (role) {
140 case "Agent":
141 return "Agent";
142 case "Agent Manager":
143 return "Manager";
144 case "System Manager":
145 return "Admin";
146 default:
147 const x: never = role;
148 throw new Error(`Invalid role: ${x}`);
149 }
150};
151
152const roleOptions: [RoleOption, ...RoleOption[]] = [
153 {
154 label: roleToLabel("Agent"),
155 value: "Agent",
156 description: __(
157 "Can work on tickets, create custom views and manage private views."
158 ),
159 },
160];
161const managerRoleOption: RoleOption = {
162 label: roleToLabel("Agent Manager"),
163 value: "Agent Manager",
164 description: __(
165 "Can invite new agents, manage tickets, create custom views and manage public views."
166 ),
167};
168if (isAdmin) {
169 roleOptions.push(managerRoleOption, {
170 label: roleToLabel("System Manager"),
171 value: "System Manager",
172 description: __("Can manage all aspects of Opendesk."),
173 });
174} else if (isManager) {
175 roleOptions.push(managerRoleOption);
176}
177const role = ref(roleOptions[0].value);
178
179const roleDescription = computed(
180 () =>
181 roleOptions.find((roleOption) => roleOption.value === role.value)!
182 .description
183);
184
185const onSubmit = async () => {
186 if (emails.value.trim() === "") {
187 toast.error(__("Please enter at least one valid email to send an invite."));
188 return;
189 }
190 await inviteByEmailResource.submit({
191 emails: emails.value,
192 roles: getInviteByEmailRoles(role.value),
193 redirect_to_path: "/opendesk",
194 app_name: "opendesk",
195 });
196 resetInputValues();
197};
198
199const resetInputValues = () => {
200 emails.value = "";
201 role.value = roleOptions[0].value;
202};
203
204const inviteByEmailResource = createResource({
205 url: "frappe.core.api.user_invitation.invite_by_email",
206 onSuccess(
207 data: Record<
208 | "disabled_user_emails"
209 | "accepted_invite_emails"
210 | "pending_invite_emails"
211 | "invited_emails",
212 string[]
213 >
214 ) {
215 resetInputValues();
216 handleInviteUserSuccess(data);
217 pendingInvitesResource.reload();
218 updateOnboardingStep?.("invite_agents");
219 capture("agents_invited", {
220 data: {
221 role: role.value,
222 },
223 });
224 },
225});
226
227const pendingInvitesResource = createResource({
228 url: "frappe.core.api.user_invitation.get_pending_invitations",
229 params: {
230 app_name: "opendesk",
231 },
232 auto: true,
233 method: "GET",
234 transform: (data) =>
235 data.filter((invite) =>
236 invite.roles.some((role) =>
237 ["Agent", "Agent Manager", "System Manager"].includes(role)
238 )
239 ),
240});
241
242const cancelInviteResource = createResource({
243 url: "frappe.core.api.user_invitation.cancel_invitation",
244 method: "PATCH",
245 onSuccess() {
246 toast.success(__("Invitation cancelled successfully"));
247 pendingInvitesResource.fetch();
248 },
249});
250
251const rolesToLabel = (roles: readonly Role[]) => {
252 const rolesSt = new Set(roles);
253 if (rolesSt.has("System Manager")) {
254 return roleToLabel("System Manager");
255 }
256 if (rolesSt.has("Agent Manager")) {
257 return roleToLabel("Agent Manager");
258 }
259 if (rolesSt.has("Agent")) {
260 return roleToLabel("Agent");
261 }
262 throw new Error(`Invalid roles: ${roles.join(", ")}`);
263};
264
265const getInviteByEmailRoles = (selectedRole: Role) => {
266 const res: Role[] = [];
267 switch (selectedRole) {
268 case "System Manager":
269 res.push("Agent", "Agent Manager");
270 break;
271 case "Agent Manager":
272 res.push("Agent");
273 break;
274 case "Agent":
275 break;
276 default:
277 const x: never = selectedRole;
278 throw new Error(`Invalid selected role: ${x}`);
279 }
280 res.push(selectedRole);
281 return res;
282};
283</script>
284
285<style scoped></style>
286