IKHSAN
ENID Baca dalam Bahasa Indonesia

opendesk

Public
Branch
main
Latest commit
08d9f3328 October 2026

InviteAgents.vue

Open raw
<template>
  <SettingsLayoutBase
    :title="__('Invite Agents')"
    :description="__('Easily invite new agents, managers, or admins.')"
  >
    <template #content>
      <form @submit.prevent="onSubmit" class="flex flex-col gap-5">
        <FormControl
          type="textarea"
          :required="true"
          :label="__('Invite by email')"
          placeholder="user1@example.com, user2@example.com, ..."
          v-model="emails"
          :debounce="100"
          :description="__('Comma separated emails to invite.')"
        />
        <div class="space-y-1.5">
          <label class="block text-base text-ink-gray-5">
            {{ __("Role") }}
            <span class="text-ink-red-6 select-none" aria-hidden="true">*</span>
          </label>
          <Select :options="roleOptions" v-model="role" required class="w-full">
            <template #suffix>
              <LucideChevronDown
                class="size-4 shrink-0 text-ink-gray-4 ms-auto"
              />
            </template>
          </Select>
          <p class="text-p-xs text-ink-gray-5">{{ roleDescription }}</p>
        </div>
        <Button
          type="submit"
          variant="solid"
          class="w-fit mt-1"
          :disabled="cancelInviteResource.loading"
          :loading="inviteByEmailResource.loading"
          >{{ __("Send Invites") }}</Button
        >
      </form>
      <template v-if="pendingInvitesResource.data?.length">
        <h2 class="mt-8 text-base-semibold">
          {{ __("Pending Invites") }}
        </h2>
        <ul class="flex flex-col gap-[0.375rem] mt-3">
          <li
            v-for="invite in pendingInvitesResource.data"
            :key="invite.name"
            class="flex items-center justify-between px-3 py-1 rounded-6 bg-surface-gray-2"
          >
            <div class="text-base">
              <span class="text-ink-gray-8">
                {{ invite.email }}
              </span>
              <span class="text-ink-gray-5">
                ({{ rolesToLabel(invite.roles) }})
              </span>
            </div>
            <div>
              <Tooltip
                :disabled="
                  inviteByEmailResource.loading || cancelInviteResource.loading
                "
                :text="__('Cancel Invitation')"
              >
                <div>
                  <Button
                    icon="lucide-x"
                    variant="ghost"
                    :loading="
                      cancelInviteResource.loading &&
                      invite.name === cancelInviteResource.params.name
                    "
                    @click="
                      () => {
                        if (
                          inviteByEmailResource.loading ||
                          cancelInviteResource.loading
                        ) {
                          return;
                        }
                        cancelInviteResource.submit({
                          name: invite.name,
                          app_name: 'opendesk',
                        });
                      }
                    "
                    v-bind="{
                      ...((cancelInviteResource.loading &&
                        cancelInviteResource.params.name !== invite.name) ||
                      inviteByEmailResource.loading
                        ? { 'aria-disabled': true, class: 'opacity-25' }
                        : {}),
                    }"
                  />
                </div>
              </Tooltip>
            </div>
          </li>
        </ul>
      </template>
    </template>
  </SettingsLayoutBase>
</template>

<script setup lang="ts">
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useAuthStore } from "@/stores/auth";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import { handleInviteUserSuccess } from "@/utils";
import {
  Button,
  FormControl,
  Select,
  Tooltip,
  createResource,
  toast,
} from "frappe-ui";
import { useOnboarding } from "@framework/ui";
import { computed, ref } from "vue";
import LucideChevronDown from "~icons/lucide/chevron-down";

const authStore = useAuthStore();
const { isAdmin, isManager } = authStore;

// @ts-expect-error
const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};

const emails = ref("");

type Role = "Agent" | "Agent Manager" | "System Manager";
type RoleOption = {
  label: string;
  value: Role;
  description: string;
};

const roleToLabel = (role: Role) => {
  switch (role) {
    case "Agent":
      return "Agent";
    case "Agent Manager":
      return "Manager";
    case "System Manager":
      return "Admin";
    default:
      const x: never = role;
      throw new Error(`Invalid role: ${x}`);
  }
};

const roleOptions: [RoleOption, ...RoleOption[]] = [
  {
    label: roleToLabel("Agent"),
    value: "Agent",
    description: __(
      "Can work on tickets, create custom views and manage private views."
    ),
  },
];
const managerRoleOption: RoleOption = {
  label: roleToLabel("Agent Manager"),
  value: "Agent Manager",
  description: __(
    "Can invite new agents, manage tickets, create custom views and manage public views."
  ),
};
if (isAdmin) {
  roleOptions.push(managerRoleOption, {
    label: roleToLabel("System Manager"),
    value: "System Manager",
    description: __("Can manage all aspects of Opendesk."),
  });
} else if (isManager) {
  roleOptions.push(managerRoleOption);
}
const role = ref(roleOptions[0].value);

const roleDescription = computed(
  () =>
    roleOptions.find((roleOption) => roleOption.value === role.value)!
      .description
);

const onSubmit = async () => {
  if (emails.value.trim() === "") {
    toast.error(__("Please enter at least one valid email to send an invite."));
    return;
  }
  await inviteByEmailResource.submit({
    emails: emails.value,
    roles: getInviteByEmailRoles(role.value),
    redirect_to_path: "/opendesk",
    app_name: "opendesk",
  });
  resetInputValues();
};

const resetInputValues = () => {
  emails.value = "";
  role.value = roleOptions[0].value;
};

const inviteByEmailResource = createResource({
  url: "frappe.core.api.user_invitation.invite_by_email",
  onSuccess(
    data: Record<
      | "disabled_user_emails"
      | "accepted_invite_emails"
      | "pending_invite_emails"
      | "invited_emails",
      string[]
    >
  ) {
    resetInputValues();
    handleInviteUserSuccess(data);
    pendingInvitesResource.reload();
    updateOnboardingStep?.("invite_agents");
    capture("agents_invited", {
      data: {
        role: role.value,
      },
    });
  },
});

const pendingInvitesResource = createResource({
  url: "frappe.core.api.user_invitation.get_pending_invitations",
  params: {
    app_name: "opendesk",
  },
  auto: true,
  method: "GET",
  transform: (data) =>
    data.filter((invite) =>
      invite.roles.some((role) =>
        ["Agent", "Agent Manager", "System Manager"].includes(role)
      )
    ),
});

const cancelInviteResource = createResource({
  url: "frappe.core.api.user_invitation.cancel_invitation",
  method: "PATCH",
  onSuccess() {
    toast.success(__("Invitation cancelled successfully"));
    pendingInvitesResource.fetch();
  },
});

const rolesToLabel = (roles: readonly Role[]) => {
  const rolesSt = new Set(roles);
  if (rolesSt.has("System Manager")) {
    return roleToLabel("System Manager");
  }
  if (rolesSt.has("Agent Manager")) {
    return roleToLabel("Agent Manager");
  }
  if (rolesSt.has("Agent")) {
    return roleToLabel("Agent");
  }
  throw new Error(`Invalid roles: ${roles.join(", ")}`);
};

const getInviteByEmailRoles = (selectedRole: Role) => {
  const res: Role[] = [];
  switch (selectedRole) {
    case "System Manager":
      res.push("Agent", "Agent Manager");
      break;
    case "Agent Manager":
      res.push("Agent");
      break;
    case "Agent":
      break;
    default:
      const x: never = selectedRole;
      throw new Error(`Invalid selected role: ${x}`);
  }
  res.push(selectedRole);
  return res;
};
</script>

<style scoped></style>
bytes
8,118
lines
286
Type
vue
Latest commit
28 October 2026