update
This commit is contained in:
@@ -12,6 +12,10 @@ class Agents extends ApiClient {
|
||||
emails,
|
||||
});
|
||||
}
|
||||
|
||||
resetPassword(agentId) {
|
||||
return axios.post(`${this.url}/${agentId}/reset_password`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Agents();
|
||||
|
||||
@@ -9,6 +9,7 @@ describe('#AgentAPI', () => {
|
||||
expect(agents).toHaveProperty('create');
|
||||
expect(agents).toHaveProperty('update');
|
||||
expect(agents).toHaveProperty('delete');
|
||||
expect(agents).toHaveProperty('resetPassword');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -34,5 +35,12 @@ describe('#AgentAPI', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#resetPassword', () => {
|
||||
agents.resetPassword(42);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/agents/42/reset_password'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,8 +54,12 @@ const codepenScriptValue = computed(() => {
|
||||
|
||||
const onCopy = async e => {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(scrubbedScript.value);
|
||||
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
try {
|
||||
await copyTextToClipboard(scrubbedScript.value);
|
||||
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
} catch {
|
||||
useAlert(t('COMPONENTS.CODE.COPY_FAILED'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
// [TODO] Use Teleport to move the modal to the end of the body
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { modalType, closeOnBackdropClick, onClose } = defineProps({
|
||||
closeOnBackdropClick: { type: Boolean, default: true },
|
||||
showCloseButton: { type: Boolean, default: true },
|
||||
onClose: { type: Function, required: true },
|
||||
// Kept temporarily for backwards compatibility while callers move to @close.
|
||||
onClose: { type: Function, default: null },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
modalType: { type: String, default: 'centered' },
|
||||
size: { type: String, default: '' },
|
||||
@@ -35,7 +36,7 @@ const handleMouseDown = () => {
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
emit('close');
|
||||
onClose();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
@@ -56,15 +57,6 @@ const onKeydown = e => {
|
||||
|
||||
useEventListener(document.body, 'mouseup', onMouseUp);
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
onMounted(() => {
|
||||
if (import.meta.env.DEV && onClose && typeof onClose === 'function') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[DEPRECATED] The 'onClose' prop is deprecated. Please use the 'close' event instead."
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal :show="show" :on-close="() => $emit('closeAccountCreateModal')">
|
||||
<woot-modal :show="show" @close="$emit('closeAccountCreateModal')">
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
|
||||
|
||||
@@ -36,7 +36,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal :show="show" size="medium" :on-close="() => $emit('close')">
|
||||
<woot-modal :show="show" size="medium" @close="$emit('close')">
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="$t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS')"
|
||||
|
||||
@@ -21,6 +21,36 @@ export const standardFieldKeys = {
|
||||
},
|
||||
};
|
||||
|
||||
export const defaultPreChatFields = [
|
||||
{
|
||||
field_type: 'standard',
|
||||
label: 'Email Id',
|
||||
name: 'emailAddress',
|
||||
type: 'email',
|
||||
placeholder: 'Please enter your email address',
|
||||
required: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
field_type: 'standard',
|
||||
label: 'Full Name',
|
||||
name: 'fullName',
|
||||
type: 'text',
|
||||
placeholder: 'Please enter your full name',
|
||||
required: false,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
field_type: 'standard',
|
||||
label: 'Phone Number',
|
||||
name: 'phoneNumber',
|
||||
type: 'text',
|
||||
placeholder: 'Please enter your phone number',
|
||||
required: false,
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const getLabel = ({ key, label }) => {
|
||||
return defaultTranslations.PRE_CHAT_FORM.FIELDS[key]
|
||||
? defaultTranslations.PRE_CHAT_FORM.FIELDS[key].LABEL
|
||||
@@ -81,7 +111,13 @@ export const getPreChatFields = ({
|
||||
// preChatFormOptions can be null when the inbox has no pre-chat form configured.
|
||||
// Default parameters only apply for undefined, not null.
|
||||
const options = preChatFormOptions || {};
|
||||
const { pre_chat_message, pre_chat_fields } = options;
|
||||
const {
|
||||
pre_chat_message = 'Share your queries or comments here.',
|
||||
pre_chat_fields: configuredPreChatFields,
|
||||
} = options;
|
||||
const pre_chat_fields = configuredPreChatFields?.length
|
||||
? configuredPreChatFields
|
||||
: defaultPreChatFields;
|
||||
let customFields = {};
|
||||
let preChatFields = {};
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ describe('#Pre chat Helpers', () => {
|
||||
customFields
|
||||
);
|
||||
});
|
||||
|
||||
it('should return standard fields when the inbox has no field configuration', () => {
|
||||
expect(
|
||||
getPreChatFields({ preChatFormOptions: null }).pre_chat_fields
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'emailAddress',
|
||||
type: 'email',
|
||||
}),
|
||||
expect.objectContaining({ name: 'fullName', type: 'text' }),
|
||||
expect.objectContaining({ name: 'phoneNumber', type: 'text' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('getFormattedPreChatFields', () => {
|
||||
it('should return correct custom fields', () => {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add agent to your team",
|
||||
"DESC": "You can add people who will be able to handle support for your inboxes.",
|
||||
"DESC": "Create an agent account directly without sending an invitation email. The email is used only for sign-in; save the generated initial password after creation.",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -37,8 +37,8 @@
|
||||
"ERROR": "Role is required"
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter an email address of the agent"
|
||||
"LABEL": "Sign-in Email",
|
||||
"PLACEHOLDER": "Enter the agent sign-in email (no email will be sent)"
|
||||
},
|
||||
"SUBMIT": "Add Agent"
|
||||
},
|
||||
@@ -92,11 +92,20 @@
|
||||
},
|
||||
"PASSWORD_RESET": {
|
||||
"ADMIN_RESET_BUTTON": "Reset Password",
|
||||
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
|
||||
"ADMIN_SUCCESS_MESSAGE": "Agent password has been reset",
|
||||
"SUCCESS_MESSAGE": "Agent password reset successfully",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
"ERROR_MESSAGE": "Could not reset the password. Please try again later"
|
||||
}
|
||||
},
|
||||
"PASSWORD_DIALOG": {
|
||||
"CREATE_TITLE": "Agent created",
|
||||
"CREATE_DESC": "Copy this initial password now and share it with the agent securely. It cannot be shown again after closing.",
|
||||
"RESET_TITLE": "Password reset",
|
||||
"RESET_DESC": "Copy this new password now and share it with the agent securely. The old password no longer works, and this password cannot be shown again after closing.",
|
||||
"COPY": "Copy password",
|
||||
"COPY_SUCCESS": "Password copied to clipboard",
|
||||
"DONE": "I have saved it"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search agents...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
|
||||
@@ -903,6 +903,7 @@
|
||||
"PRE_CHAT_FORM": {
|
||||
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
|
||||
"SET_FIELDS": "Pre chat form fields",
|
||||
"ADD_FIELD": "Add field",
|
||||
"SET_FIELDS_HEADER": {
|
||||
"FIELDS": "Fields",
|
||||
"LABEL": "Label",
|
||||
|
||||
@@ -265,7 +265,8 @@
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "Copy",
|
||||
"CODEPEN": "Open in CodePen",
|
||||
"COPY_SUCCESSFUL": "Copied to clipboard"
|
||||
"COPY_SUCCESSFUL": "Copied to clipboard",
|
||||
"COPY_FAILED": "Unable to copy. Please select and copy the code manually."
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
"SHOW_MORE": "Show More",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "添加客服至您的团队",
|
||||
"DESC": "您可以添加能够处理支持您的收件箱的人。",
|
||||
"DESC": "直接创建客服账号,不发送邀请邮件。邮箱仅用于登录,创建后请保存系统生成的初始密码。",
|
||||
"CANCEL_BUTTON_TEXT": "取消",
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
@@ -37,8 +37,8 @@
|
||||
"ERROR": "角色类型必填"
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "电子邮件地址",
|
||||
"PLACEHOLDER": "请输入客服的邮箱地址"
|
||||
"LABEL": "登录邮箱",
|
||||
"PLACEHOLDER": "请输入客服的登录邮箱(不会发送邮件)"
|
||||
},
|
||||
"SUBMIT": "添加客服"
|
||||
},
|
||||
@@ -92,11 +92,20 @@
|
||||
},
|
||||
"PASSWORD_RESET": {
|
||||
"ADMIN_RESET_BUTTON": "重置密码",
|
||||
"ADMIN_SUCCESS_MESSAGE": "一封包含重置密码说明的电子邮件已发送给代理",
|
||||
"SUCCESS_MESSAGE": "代理的密码重置成功",
|
||||
"ERROR_MESSAGE": "无法连接Woot服务器,请稍后再试"
|
||||
"ADMIN_SUCCESS_MESSAGE": "客服密码已重置",
|
||||
"SUCCESS_MESSAGE": "客服密码重置成功",
|
||||
"ERROR_MESSAGE": "密码重置失败,请稍后再试"
|
||||
}
|
||||
},
|
||||
"PASSWORD_DIALOG": {
|
||||
"CREATE_TITLE": "客服创建成功",
|
||||
"CREATE_DESC": "请立即复制并安全地将此初始密码交给客服。关闭后将无法再次查看。",
|
||||
"RESET_TITLE": "密码重置成功",
|
||||
"RESET_DESC": "请立即复制并安全地将此新密码交给客服。旧密码已失效,关闭后将无法再次查看。",
|
||||
"COPY": "复制密码",
|
||||
"COPY_SUCCESS": "密码已复制到剪贴板",
|
||||
"DONE": "我已保存"
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "搜索客服代表...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
|
||||
@@ -759,21 +759,21 @@
|
||||
"MESSENGER_HEADING": "Messenger 脚本",
|
||||
"MESSENGER_SUB_HEAD": "将此按钮放置在窗体标签中",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"DESCRIPTION": "Restrict which websites can embed your chat widget. For security, only add domains you own and trust. Add one or more domains separated by commas. Leave blank to allow all domains (not recommended for production).",
|
||||
"TITLE": "允许的域名",
|
||||
"DESCRIPTION": "限制可嵌入聊天组件的网站。为确保安全,请仅添加您拥有并信任的域名。多个域名请使用英文逗号分隔。留空表示允许所有域名(生产环境不建议这样设置)。",
|
||||
"PLACEHOLDER": "example.com, www.example.com, app.example.com"
|
||||
},
|
||||
"ALLOW_MOBILE_WEBVIEW": {
|
||||
"LABEL": "Enable widget in mobile apps",
|
||||
"SUBTITLE": "Check this if you embed the widget in iOS or Android apps. Mobile apps don't send domain information, so they would be blocked by domain restrictions unless this is enabled."
|
||||
"LABEL": "在移动应用中启用聊天组件",
|
||||
"SUBTITLE": "如果您在 iOS 或 Android 应用中嵌入聊天组件,请启用此项。移动应用不会发送域名信息;若不启用,域名限制会阻止其使用聊天组件。"
|
||||
},
|
||||
"IDENTITY_VALIDATION": {
|
||||
"TITLE": "Identity Validation",
|
||||
"DESCRIPTION": "Verify user authenticity by generating secure tokens. This prevents unauthorized users from impersonating others in your chat.",
|
||||
"TITLE": "身份验证",
|
||||
"DESCRIPTION": "通过生成安全令牌验证用户身份,防止未经授权的用户在聊天中冒充他人。",
|
||||
"SECRET_KEY": "密钥",
|
||||
"VIEW_DOCS": "View documentation",
|
||||
"REQUIRE_LABEL": "Require identity validation for all conversations",
|
||||
"REQUIRE_DESCRIPTION": "When enabled, users must provide a valid identity token to start conversations. Requests without valid tokens will be rejected."
|
||||
"VIEW_DOCS": "查看文档",
|
||||
"REQUIRE_LABEL": "对所有会话强制进行身份验证",
|
||||
"REQUIRE_DESCRIPTION": "启用后,用户必须提供有效的身份令牌才能开始会话。未提供有效令牌的请求将被拒绝。"
|
||||
},
|
||||
"INBOX_AGENTS": "客服代理们",
|
||||
"INBOX_AGENTS_SUB_TEXT": "添加或删除此收件箱中的客服",
|
||||
@@ -903,6 +903,7 @@
|
||||
"PRE_CHAT_FORM": {
|
||||
"DESCRIPTION": "预聊天表单使您能够在用户开始与您对话之前捕获用户信息。",
|
||||
"SET_FIELDS": "预聊天表单字段",
|
||||
"ADD_FIELD": "新增字段",
|
||||
"SET_FIELDS_HEADER": {
|
||||
"FIELDS": "字段",
|
||||
"LABEL": "标签",
|
||||
|
||||
@@ -265,7 +265,8 @@
|
||||
"CODE": {
|
||||
"BUTTON_TEXT": "复制",
|
||||
"CODEPEN": "在 CodePen 中打开",
|
||||
"COPY_SUCCESSFUL": "已复制到剪贴板"
|
||||
"COPY_SUCCESSFUL": "已复制到剪贴板",
|
||||
"COPY_FAILED": "无法复制,请手动选择并复制代码。"
|
||||
},
|
||||
"SHOW_MORE_BLOCK": {
|
||||
"SHOW_MORE": "显示更多",
|
||||
|
||||
@@ -14,10 +14,25 @@ import Suspended from './suspended/Index.vue';
|
||||
import NoAccounts from './noAccounts/Index.vue';
|
||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||
import v3AuthRoutes from '../../../v3/views/routes';
|
||||
import { FEATURE_FLAGS } from '../../featureFlags';
|
||||
|
||||
const PortalsArticlesPreviewPage = () =>
|
||||
import('./helpcenter/pages/PortalsArticlesPreviewPage.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
...v3AuthRoutes,
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/portals/:portalSlug/:locale/articles/preview/:articleSlug'
|
||||
),
|
||||
name: 'portals_articles_preview',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
component: PortalsArticlesPreviewPage,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId'),
|
||||
component: AppContainer,
|
||||
|
||||
+11
-19
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
|
||||
@@ -20,26 +19,9 @@ const articleById = useMapGetter('articles/articleById');
|
||||
|
||||
const article = computed(() => articleById.value(articleSlug));
|
||||
|
||||
const portalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
const portal = computed(() => portalBySlug.value(portalSlug));
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const isSaved = ref(false);
|
||||
|
||||
const articleLink = computed(() => {
|
||||
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
|
||||
const { slug: articleSlugValue } = article.value;
|
||||
const portalCustomDomain = portal.value?.custom_domain;
|
||||
return buildPortalArticleURL(
|
||||
portalSlug,
|
||||
categorySlug,
|
||||
categoryLocale,
|
||||
articleSlugValue,
|
||||
portalCustomDomain
|
||||
);
|
||||
});
|
||||
|
||||
const saveArticle = async ({ ...values }) => {
|
||||
isUpdating.value = true;
|
||||
try {
|
||||
@@ -92,7 +74,17 @@ const fetchArticleDetails = () => {
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
window.open(articleLink.value, '_blank');
|
||||
const previewRoute = router.resolve({
|
||||
name: 'portals_articles_preview',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
portalSlug,
|
||||
locale: route.params.locale || article.value?.category?.locale || 'en',
|
||||
articleSlug,
|
||||
},
|
||||
});
|
||||
const previewWindow = window.open(previewRoute.href, '_blank');
|
||||
if (previewWindow) previewWindow.opener = null;
|
||||
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
|
||||
status: article.value?.status,
|
||||
});
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const hasError = ref(false);
|
||||
|
||||
const articleById = useMapGetter('articles/articleById');
|
||||
const portalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
const article = computed(() => articleById.value(route.params.articleSlug));
|
||||
const portal = computed(() => portalBySlug.value(route.params.portalSlug));
|
||||
const portalName = computed(
|
||||
() => portal.value?.name || article.value?.portal?.name || 'Help Center'
|
||||
);
|
||||
|
||||
const closePreview = () => window.close();
|
||||
|
||||
const fetchArticle = async () => {
|
||||
isLoading.value = true;
|
||||
hasError.value = false;
|
||||
await Promise.all([
|
||||
store.dispatch('articles/show', {
|
||||
id: route.params.articleSlug,
|
||||
portalSlug: route.params.portalSlug,
|
||||
}),
|
||||
store.dispatch('portals/index'),
|
||||
]);
|
||||
hasError.value = !article.value;
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
onMounted(fetchArticle);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-full overflow-y-auto bg-n-background">
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between gap-4 px-6 py-3 border-b border-n-weak bg-n-background/95 backdrop-blur"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
文章预览
|
||||
</span>
|
||||
<span class="text-sm truncate text-n-slate-10">
|
||||
{{ portalName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg border border-n-weak text-n-slate-12 hover:bg-n-alpha-2"
|
||||
@click="closePreview"
|
||||
>
|
||||
<span class="i-lucide-x size-4" />
|
||||
关闭预览
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="w-full max-w-4xl px-6 py-10 mx-auto md:px-10 md:py-14">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center min-h-80 text-n-slate-10"
|
||||
>
|
||||
正在加载文章预览…
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="flex flex-col items-center justify-center gap-3 min-h-80 text-center"
|
||||
>
|
||||
<span class="i-lucide-file-warning size-8 text-n-slate-9" />
|
||||
<h1 class="text-xl font-semibold text-n-slate-12">无法加载文章</h1>
|
||||
<p class="text-sm text-n-slate-10">
|
||||
文章可能已被删除或当前账号无权访问。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<article v-else>
|
||||
<p v-if="article.category?.name" class="mb-3 text-sm text-n-slate-10">
|
||||
{{ article.category.name }}
|
||||
</p>
|
||||
<h1
|
||||
class="text-3xl font-semibold tracking-tight break-words text-n-slate-12 md:text-4xl"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="article.description"
|
||||
class="mt-4 text-lg leading-8 text-n-slate-10"
|
||||
>
|
||||
{{ article.description }}
|
||||
</p>
|
||||
<div class="my-8 border-t border-n-weak" />
|
||||
<div
|
||||
id="article-preview-content"
|
||||
v-dompurify-html="article.content"
|
||||
class="article-preview-content prose max-w-none text-n-slate-12 dark:prose-invert"
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-preview-content {
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+180
-156
@@ -26,188 +26,212 @@ const { currentAccount, updateAccount } = useAccount();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const labelOptions = computed(() =>
|
||||
labels.value?.length
|
||||
? labels.value.map(label => ({
|
||||
id: label.title,
|
||||
name: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
}))
|
||||
: []
|
||||
labels.value?.length
|
||||
? labels.value.map(label => ({
|
||||
id: label.title,
|
||||
name: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
}))
|
||||
: []
|
||||
);
|
||||
|
||||
const selectedLabelName = computed(() => {
|
||||
return labelToApply.value?.name ?? null;
|
||||
return labelToApply.value?.name ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
[currentAccount, labelOptions],
|
||||
() => {
|
||||
const {
|
||||
auto_resolve_after,
|
||||
auto_resolve_message,
|
||||
auto_resolve_ignore_waiting,
|
||||
auto_resolve_label,
|
||||
} = currentAccount.value?.settings || {};
|
||||
[currentAccount, labelOptions],
|
||||
() => {
|
||||
const {
|
||||
auto_resolve_after,
|
||||
auto_resolve_message,
|
||||
auto_resolve_ignore_waiting,
|
||||
auto_resolve_label,
|
||||
} = currentAccount.value?.settings || {};
|
||||
|
||||
duration.value = auto_resolve_after;
|
||||
message.value = auto_resolve_message;
|
||||
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
||||
// find the correct label option from the list
|
||||
// the single select component expects the full label object
|
||||
// in our case, the label id and name are both the same
|
||||
labelToApply.value = labelOptions.value.find(
|
||||
option => option.name === auto_resolve_label
|
||||
);
|
||||
duration.value = auto_resolve_after;
|
||||
message.value = auto_resolve_message;
|
||||
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
||||
// find the correct label option from the list
|
||||
// the single select component expects the full label object
|
||||
// in our case, the label id and name are both the same
|
||||
labelToApply.value = labelOptions.value.find(
|
||||
option => option.name === auto_resolve_label
|
||||
);
|
||||
|
||||
// Set unit based on duration and its divisibility
|
||||
if (duration.value) {
|
||||
if (duration.value % (24 * 60) === 0) {
|
||||
unit.value = DURATION_UNITS.DAYS;
|
||||
} else if (duration.value % 60 === 0) {
|
||||
unit.value = DURATION_UNITS.HOURS;
|
||||
} else {
|
||||
unit.value = DURATION_UNITS.MINUTES;
|
||||
}
|
||||
}
|
||||
// Set unit based on duration and its divisibility
|
||||
if (duration.value) {
|
||||
if (duration.value % (24 * 60) === 0) {
|
||||
unit.value = DURATION_UNITS.DAYS;
|
||||
} else if (duration.value % 60 === 0) {
|
||||
unit.value = DURATION_UNITS.HOURS;
|
||||
} else {
|
||||
unit.value = DURATION_UNITS.MINUTES;
|
||||
}
|
||||
}
|
||||
|
||||
if (duration.value) {
|
||||
isEnabled.value = true;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
if (duration.value) {
|
||||
isEnabled.value = true;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
const updateAccountSettings = async settings => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await updateAccount(settings, { silent: true });
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
await updateAccount(settings, { silent: true });
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (duration.value < 10) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (duration.value < 10) {
|
||||
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return updateAccountSettings({
|
||||
auto_resolve_after: duration.value,
|
||||
auto_resolve_message: message.value,
|
||||
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
||||
auto_resolve_label: selectedLabelName.value,
|
||||
});
|
||||
return updateAccountSettings({
|
||||
auto_resolve_after: duration.value,
|
||||
auto_resolve_message: message.value,
|
||||
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
||||
auto_resolve_label: selectedLabelName.value ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
duration.value = null;
|
||||
message.value = '';
|
||||
duration.value = null;
|
||||
message.value = '';
|
||||
|
||||
return updateAccountSettings({
|
||||
auto_resolve_after: null,
|
||||
auto_resolve_message: '',
|
||||
auto_resolve_ignore_waiting: false,
|
||||
auto_resolve_label: null,
|
||||
});
|
||||
return updateAccountSettings({
|
||||
auto_resolve_after: 0,
|
||||
auto_resolve_message: '',
|
||||
auto_resolve_ignore_waiting: false,
|
||||
auto_resolve_label: '',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAutoResolve = async () => {
|
||||
if (!isEnabled.value) handleDisable();
|
||||
if (!isEnabled.value) handleDisable();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-body-para text-n-slate-11">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-body-para text-n-slate-11">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isEnabled" class="px-5 py-4">
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
|
||||
>
|
||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model="duration"
|
||||
v-model:unit="unit"
|
||||
min="0"
|
||||
max="1438560"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||
>
|
||||
<TextArea
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
|
||||
<div
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||
>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL')
|
||||
}}
|
||||
</span>
|
||||
<Switch v-model="ignoreWaiting" />
|
||||
</div>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
|
||||
</span>
|
||||
<SingleSelect
|
||||
v-model="labelToApply"
|
||||
:options="labelOptions"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
|
||||
"
|
||||
placeholder-icon="i-lucide-chevron-down"
|
||||
placeholder-trailing-icon
|
||||
variant="faded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEnabled" class="px-5 py-4">
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
:label="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')
|
||||
"
|
||||
:help-message="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')
|
||||
"
|
||||
>
|
||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model="duration"
|
||||
v-model:unit="unit"
|
||||
min="0"
|
||||
max="1438560"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
:label="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')
|
||||
"
|
||||
:help-message="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')
|
||||
"
|
||||
>
|
||||
<TextArea
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t(
|
||||
'GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')"
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||
>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
'GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<Switch v-model="ignoreWaiting" />
|
||||
</div>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{
|
||||
t(
|
||||
'GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<SingleSelect
|
||||
v-model="labelToApply"
|
||||
:options="labelOptions"
|
||||
:placeholder="
|
||||
$t(
|
||||
'GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
placeholder-icon="i-lucide-chevron-down"
|
||||
placeholder-trailing-icon
|
||||
variant="faded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="
|
||||
t(
|
||||
'GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TemporaryPasswordDialog from './TemporaryPasswordDialog.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
@@ -15,6 +16,8 @@ const { t } = useI18n();
|
||||
const agentName = ref('');
|
||||
const agentEmail = ref('');
|
||||
const selectedRoleId = ref('agent');
|
||||
const temporaryPassword = ref('');
|
||||
const passwordDialogRef = ref(null);
|
||||
|
||||
const rules = {
|
||||
agentName: { required },
|
||||
@@ -77,9 +80,15 @@ const addAgent = async () => {
|
||||
payload.role = selectedRole.value.name;
|
||||
}
|
||||
|
||||
await store.dispatch('agents/create', payload);
|
||||
const agent = await store.dispatch('agents/create', payload);
|
||||
useAlert(t('AGENT_MGMT.ADD.API.SUCCESS_MESSAGE'));
|
||||
emit('close');
|
||||
temporaryPassword.value = agent.temporary_password || '';
|
||||
if (temporaryPassword.value) {
|
||||
await nextTick();
|
||||
passwordDialogRef.value?.open();
|
||||
} else {
|
||||
emit('close');
|
||||
}
|
||||
} catch (error) {
|
||||
const {
|
||||
response: {
|
||||
@@ -100,6 +109,11 @@ const addAgent = async () => {
|
||||
useAlert(errorResponse || attrError || errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const closePasswordDialog = () => {
|
||||
temporaryPassword.value = '';
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,5 +177,12 @@ const addAgent = async () => {
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<TemporaryPasswordDialog
|
||||
ref="passwordDialogRef"
|
||||
:password="temporaryPassword"
|
||||
:title="$t('AGENT_MGMT.PASSWORD_DIALOG.CREATE_TITLE')"
|
||||
:description="$t('AGENT_MGMT.PASSWORD_DIALOG.CREATE_DESC')"
|
||||
@close="closePasswordDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Auth from '../../../../api/auth';
|
||||
import AgentAPI from 'dashboard/api/agents';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import TemporaryPasswordDialog from './TemporaryPasswordDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -50,7 +51,8 @@ const { t } = useI18n();
|
||||
const agentName = ref(props.name);
|
||||
const agentAvailability = ref(props.availability);
|
||||
const selectedRoleId = ref(props.customRoleId || props.type);
|
||||
const agentCredentials = ref({ email: props.email });
|
||||
const temporaryPassword = ref('');
|
||||
const passwordDialogRef = ref(null);
|
||||
|
||||
const rules = {
|
||||
agentName: { required, minLength: minLength(1) },
|
||||
@@ -145,8 +147,11 @@ const editAgent = async () => {
|
||||
|
||||
const resetPassword = async () => {
|
||||
try {
|
||||
await Auth.resetPassword(agentCredentials.value);
|
||||
const response = await AgentAPI.resetPassword(props.id);
|
||||
temporaryPassword.value = response.data.temporary_password;
|
||||
useAlert(t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_SUCCESS_MESSAGE'));
|
||||
await nextTick();
|
||||
passwordDialogRef.value?.open();
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_MGMT.EDIT.PASSWORD_RESET.ERROR_MESSAGE'));
|
||||
}
|
||||
@@ -207,7 +212,7 @@ const resetPassword = async () => {
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<div class="w-[50%] ltr:text-left rtl:text-right">
|
||||
<Button
|
||||
v-if="provider !== 'saml'"
|
||||
v-if="provider === 'email' || !provider"
|
||||
ghost
|
||||
type="button"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
@@ -233,5 +238,12 @@ const resetPassword = async () => {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<TemporaryPasswordDialog
|
||||
ref="passwordDialogRef"
|
||||
:password="temporaryPassword"
|
||||
:title="$t('AGENT_MGMT.PASSWORD_DIALOG.RESET_TITLE')"
|
||||
:description="$t('AGENT_MGMT.PASSWORD_DIALOG.RESET_DESC')"
|
||||
@close="temporaryPassword = ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
defineProps({
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const open = () => dialogRef.value?.open();
|
||||
const close = () => dialogRef.value?.close();
|
||||
|
||||
const copyPassword = async password => {
|
||||
await copyTextToClipboard(password);
|
||||
useAlert(t('AGENT_MGMT.PASSWORD_DIALOG.COPY_SUCCESS'));
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="md"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:show-cancel-button="false"
|
||||
:confirm-button-label="$t('AGENT_MGMT.PASSWORD_DIALOG.DONE')"
|
||||
@confirm="close"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<code
|
||||
class="flex-1 px-3 py-2 overflow-x-auto font-mono text-base rounded-lg bg-n-solid-1 text-n-slate-12 outline outline-1 outline-n-weak"
|
||||
>{{ password }}</code
|
||||
>
|
||||
<Button
|
||||
outline
|
||||
slate
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
:label="$t('AGENT_MGMT.PASSWORD_DIALOG.COPY')"
|
||||
@click="copyPassword(password)"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+30
-1
@@ -9,6 +9,7 @@ import { getPreChatFields } from 'dashboard/helper/preChat';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import AddAttribute from 'dashboard/routes/dashboard/settings/attributes/AddAttribute.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
@@ -26,6 +27,7 @@ const customAttributes = useMapGetter('attributes/getAttributes');
|
||||
const preChatFormEnabled = ref(false);
|
||||
const preChatMessage = ref('');
|
||||
const preChatFields = ref([]);
|
||||
const showAddFieldModal = ref(false);
|
||||
|
||||
const preChatFieldOptions = computed(() => {
|
||||
const { pre_chat_form_options: preChatFormOptions } = props.inbox;
|
||||
@@ -66,6 +68,14 @@ const changePreChatFieldFieldsOrder = updatedPreChatFieldOptions => {
|
||||
preChatFields.value = updatedPreChatFieldOptions;
|
||||
};
|
||||
|
||||
const openAddFieldModal = () => {
|
||||
showAddFieldModal.value = true;
|
||||
};
|
||||
|
||||
const closeAddFieldModal = () => {
|
||||
showAddFieldModal.value = false;
|
||||
};
|
||||
|
||||
const updateInbox = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
@@ -87,8 +97,12 @@ const updateInbox = async () => {
|
||||
};
|
||||
|
||||
watch(() => props.inbox, setDefaults);
|
||||
watch(customAttributes, setDefaults, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
if (!customAttributes.value.length) {
|
||||
await store.dispatch('attributes/get');
|
||||
}
|
||||
setDefaults();
|
||||
});
|
||||
</script>
|
||||
@@ -118,6 +132,16 @@ onMounted(() => {
|
||||
</div>
|
||||
<form class="flex flex-col" @submit.prevent="updateInbox">
|
||||
<div v-if="preChatFormEnabled">
|
||||
<div class="flex justify-end mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('INBOX_MGMT.PRE_CHAT_FORM.ADD_FIELD')"
|
||||
@click="openAddFieldModal"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<table
|
||||
class="min-w-full table-auto outline outline-1 -outline-offset-1 outline-n-weak rounded-xl"
|
||||
@@ -149,6 +173,11 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<AddAttribute
|
||||
v-if="showAddFieldModal"
|
||||
:on-close="closeAddFieldModal"
|
||||
:selected-attribute-model-tab="1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -74,6 +75,7 @@ export default {
|
||||
AccountHealth,
|
||||
Widget,
|
||||
AccessToken,
|
||||
FormInput,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -749,12 +751,14 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<SettingsFieldSection :label="inboxNameLabel">
|
||||
<woot-input
|
||||
<FormInput
|
||||
v-model="selectedInboxName"
|
||||
class="[&>input]:!mb-0"
|
||||
:class="{ error: v$.selectedInboxName.$error }"
|
||||
name="inboxName"
|
||||
label=""
|
||||
spacing="compact"
|
||||
:has-error="v$.selectedInboxName.$error"
|
||||
:placeholder="inboxNamePlaceHolder"
|
||||
:error="
|
||||
:error-message="
|
||||
v$.selectedInboxName.$error
|
||||
? $t('INBOX_MGMT.ADD.CHANNEL_NAME.ERROR')
|
||||
: ''
|
||||
@@ -768,16 +772,18 @@ export default {
|
||||
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
|
||||
"
|
||||
>
|
||||
<woot-input
|
||||
<FormInput
|
||||
v-model="webhookUrl"
|
||||
class="[&>input]:!mb-0"
|
||||
:class="{ error: v$.webhookUrl.$error }"
|
||||
name="webhookUrl"
|
||||
label=""
|
||||
spacing="compact"
|
||||
:has-error="v$.webhookUrl.$error"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:error="
|
||||
:error-message="
|
||||
v$.webhookUrl.$error
|
||||
? $t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.ERROR'
|
||||
@@ -807,9 +813,11 @@ export default {
|
||||
v-if="isAWebWidgetInbox"
|
||||
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
|
||||
>
|
||||
<woot-input
|
||||
<FormInput
|
||||
v-model="channelWebsiteUrl"
|
||||
class="[&>input]:!mb-0"
|
||||
name="channelWebsiteUrl"
|
||||
label=""
|
||||
spacing="compact"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER'
|
||||
@@ -926,9 +934,11 @@ export default {
|
||||
)
|
||||
"
|
||||
>
|
||||
<woot-input
|
||||
<FormInput
|
||||
v-model="channelWelcomeTitle"
|
||||
class="[&>input]:!mb-0"
|
||||
name="channelWelcomeTitle"
|
||||
label=""
|
||||
spacing="compact"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.PLACEHOLDER'
|
||||
@@ -1035,14 +1045,16 @@ export default {
|
||||
)
|
||||
"
|
||||
>
|
||||
<woot-input
|
||||
<FormInput
|
||||
v-model="widgetBubbleLauncherTitle"
|
||||
name="widgetBubbleLauncherTitle"
|
||||
label=""
|
||||
spacing="compact"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
class="[&>input]:!mb-0"
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
|
||||
@@ -89,8 +89,13 @@ export default {
|
||||
@blur="v$.description.$touch"
|
||||
/>
|
||||
<div class="w-full flex items-center gap-2">
|
||||
<input v-model="state.allowAutoAssign" type="checkbox" :value="true" />
|
||||
<label for="conversation_creation">
|
||||
<input
|
||||
id="team-allow-auto-assign"
|
||||
v-model="state.allowAutoAssign"
|
||||
type="checkbox"
|
||||
:value="true"
|
||||
/>
|
||||
<label for="team-allow-auto-assign">
|
||||
{{ $t('TEAMS_SETTINGS.FORM.AUTO_ASSIGN.LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import TeamForm from '../TeamForm.vue';
|
||||
|
||||
describe('TeamForm.vue', () => {
|
||||
it('toggles auto assignment when its label is clicked', async () => {
|
||||
const wrapper = shallowMount(TeamForm, {
|
||||
props: {
|
||||
formData: {
|
||||
allow_auto_assign: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const checkbox = wrapper.get('#team-allow-auto-assign');
|
||||
|
||||
expect(checkbox.element.checked).toBe(false);
|
||||
|
||||
await wrapper.get('label[for="team-allow-auto-assign"]').trigger('click');
|
||||
|
||||
expect(checkbox.element.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,8 +55,11 @@ export const actions = {
|
||||
commit(types.default.SET_AGENT_CREATING_STATUS, true);
|
||||
try {
|
||||
const response = await AgentAPI.create(agentInfo);
|
||||
commit(types.default.ADD_AGENT, response.data);
|
||||
const agent = { ...response.data };
|
||||
delete agent.temporary_password;
|
||||
commit(types.default.ADD_AGENT, agent);
|
||||
commit(types.default.SET_AGENT_CREATING_STATUS, false);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_AGENT_CREATING_STATUS, false);
|
||||
throw error;
|
||||
|
||||
@@ -31,8 +31,14 @@ describe('#actions', () => {
|
||||
|
||||
describe('#create', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: agentList[0] });
|
||||
await actions.create({ commit }, agentList[0]);
|
||||
const responseAgent = {
|
||||
...agentList[0],
|
||||
temporary_password: 'InitialPassword1!',
|
||||
};
|
||||
axios.post.mockResolvedValue({ data: responseAgent });
|
||||
const createdAgent = await actions.create({ commit }, agentList[0]);
|
||||
|
||||
expect(createdAgent).toEqual(responseAgent);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_AGENT_CREATING_STATUS, true],
|
||||
[types.default.ADD_AGENT, agentList[0]],
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const template = document.createElement('template');
|
||||
|
||||
template.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
direction: ltr;
|
||||
font-family: var(--mdc-icon-font, 'Material Icons');
|
||||
font-feature-settings: 'liga';
|
||||
font-size: var(--mdc-icon-size, 24px);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
<slot></slot>
|
||||
`;
|
||||
|
||||
class MaterialIconElement extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' }).appendChild(
|
||||
template.content.cloneNode(true)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('mwc-icon')) {
|
||||
customElements.define('mwc-icon', MaterialIconElement);
|
||||
}
|
||||
@@ -6,15 +6,45 @@
|
||||
* @throws {Error} unable to copy text to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async data => {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
|
||||
let clipboardError;
|
||||
|
||||
try {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
throw new Error('Clipboard API is unavailable');
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||
clipboardError = error;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
try {
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
if (!document.execCommand?.('copy')) {
|
||||
throw new Error('Fallback copy command failed');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unable to copy text to clipboard: ${clipboardError.message}`
|
||||
);
|
||||
} finally {
|
||||
textarea.remove();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { copyTextToClipboard, handleOtpPaste } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
const mockExecCommand = vi.fn();
|
||||
|
||||
const setClipboard = clipboard => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: clipboard,
|
||||
});
|
||||
};
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setClipboard({ writeText: mockWriteText });
|
||||
document.execCommand = mockExecCommand;
|
||||
mockExecCommand.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
@@ -133,23 +139,38 @@ describe('copyTextToClipboard', () => {
|
||||
it('throws error when clipboard API fails', async () => {
|
||||
const error = new Error('Clipboard access denied');
|
||||
mockWriteText.mockRejectedValueOnce(error);
|
||||
mockExecCommand.mockReturnValueOnce(false);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard: Clipboard access denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles clipboard API not available', async () => {
|
||||
// Temporarily remove clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
delete navigator.clipboard;
|
||||
it('falls back when clipboard API is not available', async () => {
|
||||
setClipboard(undefined);
|
||||
|
||||
await copyTextToClipboard('test');
|
||||
|
||||
expect(mockExecCommand).toHaveBeenCalledWith('copy');
|
||||
expect(document.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back when clipboard API rejects the request', async () => {
|
||||
mockWriteText.mockRejectedValueOnce(new Error('Clipboard access denied'));
|
||||
|
||||
await copyTextToClipboard('test');
|
||||
|
||||
expect(mockExecCommand).toHaveBeenCalledWith('copy');
|
||||
expect(document.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
|
||||
it('throws when neither clipboard method is available', async () => {
|
||||
setClipboard(undefined);
|
||||
mockExecCommand.mockReturnValueOnce(false);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard:'
|
||||
'Unable to copy text to clipboard: Clipboard API is unavailable'
|
||||
);
|
||||
|
||||
// Restore clipboard API
|
||||
navigator.clipboard = originalClipboard;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
parseAPIErrorResponse,
|
||||
} from 'dashboard/store/utils/api';
|
||||
import wootAPI from './apiClient';
|
||||
import {
|
||||
getLoginRedirectURL,
|
||||
getCredentialsFromEmail,
|
||||
} from '../helpers/AuthHelper';
|
||||
import { getLoginRedirectURL } from '../helpers/AuthHelper';
|
||||
|
||||
export const login = async ({
|
||||
ssoAccountId,
|
||||
@@ -49,23 +46,6 @@ export const login = async ({
|
||||
}
|
||||
};
|
||||
|
||||
export const register = async creds => {
|
||||
try {
|
||||
const { fullName, accountName } = getCredentialsFromEmail(creds.email);
|
||||
const response = await wootAPI.post('api/v1/accounts.json', {
|
||||
account_name: accountName,
|
||||
user_full_name: fullName,
|
||||
email: creds.email,
|
||||
password: creds.password,
|
||||
h_captcha_client_response: creds.hCaptchaClientResponse,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
|
||||
return wootAPI.post('resend_confirmation', {
|
||||
email,
|
||||
|
||||
@@ -95,7 +95,7 @@ const currentInputType = computed(() => {
|
||||
sm
|
||||
link
|
||||
:icon="isPasswordVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
class="absolute inset-y-0 right-0 pr-3"
|
||||
class="absolute top-0 right-0 h-10 pr-3"
|
||||
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'"
|
||||
:aria-pressed="isPasswordVisible"
|
||||
@click="togglePasswordVisibility()"
|
||||
|
||||
@@ -33,14 +33,6 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the URL is an invalid path, redirect to login page
|
||||
// Disable navigation to signup page if signups are disabled
|
||||
// Signup route has an attribute (requireSignupEnabled) in it's definition
|
||||
const isAnInalidSignupNavigation =
|
||||
chatwootConfig.signupEnabled !== 'true' &&
|
||||
to.meta &&
|
||||
to.meta.requireSignupEnabled;
|
||||
|
||||
// Disable navigation to SAML login if enterprise is not enabled
|
||||
// SAML route has an attribute (requireEnterprise) in it's definition
|
||||
const isEnterpriseOnlyPath =
|
||||
@@ -48,7 +40,7 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
||||
to.meta &&
|
||||
to.meta.requireEnterprise;
|
||||
|
||||
if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) {
|
||||
if (!to.name || isEnterpriseOnlyPath) {
|
||||
next(frontendURL('login'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,14 +57,6 @@ describe('#validateRouteAccess', () => {
|
||||
expect(next).toHaveBeenCalledWith('/app/login');
|
||||
});
|
||||
|
||||
it('redirects to login if signup is disabled', () => {
|
||||
validateRouteAccess({ meta: { requireSignupEnabled: true } }, next, {
|
||||
signupEnabled: 'true',
|
||||
});
|
||||
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalledWith('/app/login');
|
||||
});
|
||||
|
||||
it('continues to the route in every other case', () => {
|
||||
validateRouteAccess({ name: 'reset_password' }, next);
|
||||
expect(clearBrowserSessionCookies).not.toHaveBeenCalled();
|
||||
|
||||
@@ -94,9 +94,6 @@ export default {
|
||||
Boolean(window.chatwootConfig.googleOAuthClientId)
|
||||
);
|
||||
},
|
||||
showSignupLink() {
|
||||
return window.chatwootConfig.signupEnabled === 'true';
|
||||
},
|
||||
showSamlLogin() {
|
||||
return this.allowedLoginMethods.includes('saml');
|
||||
},
|
||||
@@ -233,26 +230,9 @@ export default {
|
||||
class="flex flex-col w-full min-h-screen py-20 bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<section class="max-w-5xl mx-auto">
|
||||
<img
|
||||
:src="globalConfig.logo"
|
||||
:alt="globalConfig.installationName"
|
||||
class="block w-auto h-8 mx-auto dark:hidden"
|
||||
/>
|
||||
<img
|
||||
v-if="globalConfig.logoDark"
|
||||
:src="globalConfig.logoDark"
|
||||
:alt="globalConfig.installationName"
|
||||
class="hidden w-auto h-8 mx-auto dark:block"
|
||||
/>
|
||||
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
|
||||
<h2 class="text-3xl font-medium text-center text-n-slate-12">
|
||||
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
|
||||
</h2>
|
||||
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
|
||||
{{ $t('COMMON.OR') }}
|
||||
<router-link to="auth/signup" class="lowercase text-link text-n-brand">
|
||||
{{ $t('LOGIN.CREATE_NEW_ACCOUNT') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- MFA Verification Section -->
|
||||
|
||||
@@ -2,7 +2,6 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Login from './login/Index.vue';
|
||||
import SamlLogin from './login/Saml.vue';
|
||||
import Signup from './auth/signup/Index.vue';
|
||||
import ResetPassword from './auth/reset/password/Index.vue';
|
||||
import Confirmation from './auth/confirmation/Index.vue';
|
||||
import VerifyEmail from './auth/verify-email/Index.vue';
|
||||
@@ -32,12 +31,6 @@ export default [
|
||||
target: route.query.target,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/signup'),
|
||||
name: 'auth_signup',
|
||||
component: Signup,
|
||||
meta: { requireSignupEnabled: true },
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/confirmation'),
|
||||
name: 'auth_confirmation',
|
||||
|
||||
Reference in New Issue
Block a user