refactor: 移除 SAML/LDAP/MFA 登录方式,仅保留本地账号密码和 OIDC
后端移除: - SAML: auth/saml.go, handler/saml_handler.go, account_saml_settings_handler.go, model/account_saml_settings.go, model/saml_idp_config.go, repo/*.go - LDAP: auth/ldap.go, handler/ldap_handler.go, model/account_ldap_settings.go, repo/account_ldap_settings_repo.go - MFA: auth/mfa.go, handler/mfa_handler.go - auth_service: 移除 mfaService 依赖、MFARequired 字段、LoginWithMFA 方法 - auth_handler: 移除 LoginMFA handler、MFA 分支逻辑 - bootstrap: 移除 SAML/LDAP/MFA service 初始化和 handler 注册 - sso_middleware: 精简为仅支持 OIDC provider - router: 移除 SAML/LDAP/MFA 路由注册 - config: 移除 SAMLConfig/LDAPConfig struct 和 defaults 前端移除: - v3/login: 移除 MFA 验证流程和 SAML 登录入口 - v3/api/auth: 移除 MFA 响应处理 - v3/routes: 移除 SSO login 路由 - dashboard: 移除 MFA 设置页面、SAML 安全设置页面 - i18n: 移除 mfa.json - featureFlags: 移除 SAML feature flag .env.example / .env: 移除 SAML/LDAP 配置段
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class MfaAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('profile/mfa', { accountScoped: false });
|
||||
}
|
||||
|
||||
enable() {
|
||||
return axios.post(`${this.url}`);
|
||||
}
|
||||
|
||||
verify(otpCode) {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
});
|
||||
}
|
||||
|
||||
regenerateBackupCodes(otpCode) {
|
||||
return axios.post(`${this.url}/backup_codes`, { otp_code: otpCode });
|
||||
}
|
||||
}
|
||||
|
||||
export default new MfaAPI();
|
||||
@@ -1,26 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class SamlSettingsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('saml_settings', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
update(data) {
|
||||
return axios.put(this.url, { saml_settings: data });
|
||||
}
|
||||
|
||||
delete() {
|
||||
return axios.delete(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
export default new SamlSettingsAPI();
|
||||
@@ -1,328 +0,0 @@
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { handleOtpPaste } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mfaToken: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['verified', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const OTP = 'otp';
|
||||
const BACKUP = 'backup';
|
||||
|
||||
// State
|
||||
const verificationMethod = ref(OTP);
|
||||
const otpDigits = ref(['', '', '', '', '', '']);
|
||||
const backupCode = ref('');
|
||||
const isVerifying = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const helpModalRef = ref(null);
|
||||
const otpInputRefs = ref([]);
|
||||
|
||||
// Computed
|
||||
const otpCode = computed(() => otpDigits.value.join(''));
|
||||
const canSubmit = computed(() =>
|
||||
verificationMethod.value === OTP
|
||||
? otpCode.value.length === 6
|
||||
: backupCode.value.length === 8
|
||||
);
|
||||
|
||||
const contactDescKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
|
||||
);
|
||||
|
||||
const focusInput = i => otpInputRefs.value[i]?.focus();
|
||||
|
||||
// Verification
|
||||
const handleVerification = async () => {
|
||||
if (!canSubmit.value || isVerifying.value) return;
|
||||
|
||||
isVerifying.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
mfa_token: props.mfaToken,
|
||||
};
|
||||
|
||||
if (verificationMethod.value === OTP) {
|
||||
payload.otp_code = otpCode.value;
|
||||
} else {
|
||||
payload.backup_code = backupCode.value;
|
||||
}
|
||||
|
||||
const response = await axios.post('/auth/sign_in', payload);
|
||||
|
||||
// Set auth credentials and redirect
|
||||
if (response.data && response.headers) {
|
||||
// Store auth credentials in cookies
|
||||
const authData = {
|
||||
'access-token': response.headers['access-token'],
|
||||
'token-type': response.headers['token-type'],
|
||||
client: response.headers.client,
|
||||
expiry: response.headers.expiry,
|
||||
uid: response.headers.uid,
|
||||
};
|
||||
|
||||
// Store in cookies for auth
|
||||
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/app/';
|
||||
} else {
|
||||
emit('verified', response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
|
||||
|
||||
// Clear inputs on error
|
||||
if (verificationMethod.value === OTP) {
|
||||
otpDigits.value.fill('');
|
||||
await nextTick();
|
||||
focusInput(0);
|
||||
} else {
|
||||
backupCode.value = '';
|
||||
}
|
||||
} finally {
|
||||
isVerifying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// OTP Input Handling
|
||||
const handleOtpInput = async i => {
|
||||
const v = otpDigits.value[i];
|
||||
|
||||
// Only allow numbers
|
||||
if (!/^\d*$/.test(v)) {
|
||||
otpDigits.value[i] = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to next input if value entered
|
||||
if (v && i < 5) {
|
||||
await nextTick();
|
||||
focusInput(i + 1);
|
||||
}
|
||||
|
||||
// Auto-submit if all digits entered
|
||||
if (otpCode.value.length === 6) {
|
||||
handleVerification();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackspace = (e, i) => {
|
||||
if (!otpDigits.value[i] && i > 0) {
|
||||
e.preventDefault();
|
||||
focusInput(i - 1);
|
||||
otpDigits.value[i - 1] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpCodePaste = e => {
|
||||
e.preventDefault();
|
||||
const code = handleOtpPaste(e, 6);
|
||||
|
||||
if (code) {
|
||||
otpDigits.value = code.split('');
|
||||
handleVerification();
|
||||
}
|
||||
};
|
||||
|
||||
// Alternative Actions
|
||||
const handleTryAnotherMethod = () => {
|
||||
// Toggle between methods
|
||||
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
|
||||
otpDigits.value.fill('');
|
||||
backupCode.value = '';
|
||||
errorMessage.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<div
|
||||
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<div
|
||||
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
|
||||
>
|
||||
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('MFA_VERIFICATION.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab Selection -->
|
||||
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
|
||||
<button
|
||||
v-for="method in [OTP, BACKUP]"
|
||||
:key="method"
|
||||
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
|
||||
:class="
|
||||
verificationMethod === method
|
||||
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="verificationMethod = method"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification Form -->
|
||||
<form class="space-y-4" @submit.prevent="handleVerification">
|
||||
<!-- OTP Code Input -->
|
||||
<div v-if="verificationMethod === OTP">
|
||||
<label class="block text-sm font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
|
||||
</label>
|
||||
<div class="flex justify-between gap-2">
|
||||
<input
|
||||
v-for="(_, i) in otpDigits"
|
||||
:key="i"
|
||||
ref="otpInputRefs"
|
||||
v-model="otpDigits[i]"
|
||||
type="text"
|
||||
maxlength="1"
|
||||
pattern="[0-9]"
|
||||
inputmode="numeric"
|
||||
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
@input="handleOtpInput(i)"
|
||||
@keydown.left.prevent="focusInput(i - 1)"
|
||||
@keydown.right.prevent="focusInput(i + 1)"
|
||||
@keydown.backspace="handleBackspace($event, i)"
|
||||
@paste="handleOtpCodePaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Code Input -->
|
||||
<div v-if="verificationMethod === BACKUP">
|
||||
<FormInput
|
||||
v-model="backupCode"
|
||||
name="backup_code"
|
||||
type="text"
|
||||
data-testid="backup_code_input"
|
||||
:tabindex="1"
|
||||
required
|
||||
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
|
||||
:placeholder="
|
||||
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
|
||||
"
|
||||
@keyup.enter="handleVerification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
data-testid="submit_button"
|
||||
class="w-full"
|
||||
:tabindex="2"
|
||||
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
|
||||
:disabled="!canSubmit || isVerifying"
|
||||
:is-loading="isVerifying"
|
||||
/>
|
||||
|
||||
<!-- Alternative Actions -->
|
||||
<div class="text-center flex items-center flex-col gap-2 pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="2"
|
||||
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
|
||||
@click="handleTryAnotherMethod"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="3"
|
||||
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="4"
|
||||
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
|
||||
@click="helpModalRef?.open()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<Dialog
|
||||
ref="helpModalRef"
|
||||
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
|
||||
:show-confirm-button="false"
|
||||
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
|
||||
@confirm="helpModalRef?.close()"
|
||||
>
|
||||
<div class="space-y-4 text-sm text-n-slate-11">
|
||||
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
|
||||
<h4 class="font-medium text-n-slate-12 mb-2">
|
||||
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
|
||||
</h4>
|
||||
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,7 +42,6 @@ export const FEATURE_FLAGS = {
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
|
||||
SAML: 'saml',
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
@@ -56,7 +55,6 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.CUSTOM_ROLES,
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.SAML,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
|
||||
];
|
||||
|
||||
@@ -18,7 +18,6 @@ const FEATURE_HELP_URLS = {
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
};
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
@@ -83,7 +82,6 @@ export default {
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"MFA_SETTINGS": {
|
||||
"TITLE": "Two-Factor Authentication",
|
||||
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
|
||||
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
|
||||
"STATUS_TITLE": "Authentication Status",
|
||||
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled",
|
||||
"STATUS_ENABLED": "Two-factor authentication is active",
|
||||
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
|
||||
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
|
||||
"ENHANCE_SECURITY": "Enhance Your Account Security",
|
||||
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
|
||||
"SETUP": {
|
||||
"STEP_NUMBER_1": "1",
|
||||
"STEP_NUMBER_2": "2",
|
||||
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
|
||||
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
|
||||
"LOADING_QR": "Loading...",
|
||||
"MANUAL_ENTRY": "Can't scan? Enter code manually",
|
||||
"SECRET_KEY": "Secret Key",
|
||||
"COPY": "Copy",
|
||||
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
|
||||
"ENTER_CODE_PLACEHOLDER": "000000",
|
||||
"VERIFY_BUTTON": "Verify & Continue",
|
||||
"CANCEL": "Cancel",
|
||||
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
|
||||
"INVALID_CODE": "Invalid verification code",
|
||||
"SECRET_COPIED": "Secret key copied to clipboard",
|
||||
"SUCCESS": "Two-factor authentication has been enabled successfully"
|
||||
},
|
||||
"BACKUP": {
|
||||
"TITLE": "Save Your Backup Codes",
|
||||
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
|
||||
"IMPORTANT": "Important:",
|
||||
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
|
||||
"DOWNLOAD": "Download",
|
||||
"COPY_ALL": "Copy All",
|
||||
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
|
||||
"COMPLETE_SETUP": "Complete Setup",
|
||||
"CODES_COPIED": "Backup codes copied to clipboard"
|
||||
},
|
||||
"MANAGEMENT": {
|
||||
"BACKUP_CODES": "Backup Codes",
|
||||
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
|
||||
"REGENERATE": "Regenerate Backup Codes",
|
||||
"DISABLE_MFA": "Disable 2FA",
|
||||
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
|
||||
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
|
||||
},
|
||||
"DISABLE": {
|
||||
"TITLE": "Disable Two-Factor Authentication",
|
||||
"DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.",
|
||||
"PASSWORD": "Password",
|
||||
"OTP_CODE": "Verification Code",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"BACKUP_CODE": "Backup Code",
|
||||
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
|
||||
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
|
||||
"USE_OTP_CODE": "Use a verification code from your authenticator app",
|
||||
"CONFIRM": "Disable 2FA",
|
||||
"CANCEL": "Cancel",
|
||||
"SUCCESS": "Two-factor authentication has been disabled",
|
||||
"ERROR": "Failed to disable MFA. Please check your credentials."
|
||||
},
|
||||
"REGENERATE": {
|
||||
"TITLE": "Regenerate Backup Codes",
|
||||
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
|
||||
"OTP_CODE": "Verification Code",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"CONFIRM": "Generate New Codes",
|
||||
"CANCEL": "Cancel",
|
||||
"NEW_CODES_TITLE": "New Backup Codes Generated",
|
||||
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
|
||||
"CODES_IMPORTANT": "Important:",
|
||||
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
|
||||
"DOWNLOAD_CODES": "Download Codes",
|
||||
"COPY_ALL_CODES": "Copy All Codes",
|
||||
"CODES_SAVED": "I've Saved My Codes",
|
||||
"SUCCESS": "New backup codes have been generated",
|
||||
"ERROR": "Failed to regenerate backup codes"
|
||||
}
|
||||
},
|
||||
"MFA_VERIFICATION": {
|
||||
"TITLE": "Two-Factor Authentication",
|
||||
"DESCRIPTION": "Enter your verification code to continue",
|
||||
"AUTHENTICATOR_APP": "Authenticator App",
|
||||
"BACKUP_CODE": "Backup Code",
|
||||
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
|
||||
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
|
||||
"BACKUP_CODE_PLACEHOLDER": "000000",
|
||||
"VERIFY_BUTTON": "Verify",
|
||||
"TRY_ANOTHER_METHOD": "Try another verification method",
|
||||
"CANCEL_LOGIN": "Cancel and return to login",
|
||||
"HELP_TEXT": "Having trouble signing in?",
|
||||
"LEARN_MORE": "Learn more about 2FA",
|
||||
"HELP_MODAL": {
|
||||
"TITLE": "Two-Factor Authentication Help",
|
||||
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
|
||||
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
|
||||
"BACKUP_TITLE": "Using a Backup Code",
|
||||
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
|
||||
"CONTACT_TITLE": "Need More Help?",
|
||||
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
|
||||
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
|
||||
},
|
||||
"VERIFICATION_FAILED": "Verification failed. Please try again."
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import integrations from './integrations.json';
|
||||
import labelsMgmt from './labelsMgmt.json';
|
||||
import login from './login.json';
|
||||
import macros from './macros.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
import report from './report.json';
|
||||
import resetPassword from './resetPassword.json';
|
||||
@@ -69,7 +68,6 @@ export default {
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
...macros,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...report,
|
||||
...resetPassword,
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
{
|
||||
"MFA_SETTINGS": {
|
||||
"TITLE": "两步验证",
|
||||
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
|
||||
"DESCRIPTION": "使用基于时间的一次性密码(TOTP)为您的帐户添加额外的一层安全保护",
|
||||
"STATUS_TITLE": "验证状态",
|
||||
"STATUS_DESCRIPTION": "管理您的二步验证设置和备份码",
|
||||
"ENABLED": "已启用",
|
||||
"DISABLED": "已禁用",
|
||||
"STATUS_ENABLED": "两步验证已启用",
|
||||
"STATUS_ENABLED_DESC": "您的帐户受到额外的安全层保护",
|
||||
"ENABLE_BUTTON": "启用两步验证",
|
||||
"ENHANCE_SECURITY": "增强您的帐户安全",
|
||||
"ENHANCE_SECURITY_DESC": "两步验证除了您的密码外还需要额外的身份验证程序的验证码,从而增加了额外的安全层次。",
|
||||
"SETUP": {
|
||||
"STEP_NUMBER_1": "1",
|
||||
"STEP_NUMBER_2": "2",
|
||||
"STEP1_TITLE": "使用您的身份验证器应用程序扫描二维码",
|
||||
"STEP1_DESCRIPTION": "使用 Google 身份验证器、Authy 或者任何 TOTP 兼容应用程序",
|
||||
"LOADING_QR": "加载中...",
|
||||
"MANUAL_ENTRY": "无法扫描?手动输入代码",
|
||||
"SECRET_KEY": "密钥",
|
||||
"COPY": "复制",
|
||||
"ENTER_CODE": "从您的身份验证程序中输入6位数字代码",
|
||||
"ENTER_CODE_PLACEHOLDER": "000000",
|
||||
"VERIFY_BUTTON": "验证并继续",
|
||||
"CANCEL": "取消",
|
||||
"ERROR_STARTING": "MFA 未启用。请与管理员联系。",
|
||||
"INVALID_CODE": "无效的验证码",
|
||||
"SECRET_COPIED": "密钥已复制到剪贴板",
|
||||
"SUCCESS": "已成功启用两步验证"
|
||||
},
|
||||
"BACKUP": {
|
||||
"TITLE": "保存您的备份代码",
|
||||
"DESCRIPTION": "妥善保管这些备份代码,如果您无法访问身份验证器,每个代码可以使用一次",
|
||||
"IMPORTANT": "重要:",
|
||||
"IMPORTANT_NOTE": " 将这些代码保存到一个安全的位置。您将无法再次看到它们。",
|
||||
"DOWNLOAD": "下载",
|
||||
"COPY_ALL": "复制全部",
|
||||
"CONFIRM": "我已经将我的备份代码保存在一个安全的位置,并且知道我将无法再次看到它们。",
|
||||
"COMPLETE_SETUP": "完成设置",
|
||||
"CODES_COPIED": "备份代码已复制到剪贴板"
|
||||
},
|
||||
"MANAGEMENT": {
|
||||
"BACKUP_CODES": "备份代码",
|
||||
"BACKUP_CODES_DESC": "如果您丢失或使用了您现有的代码,则生成新代码",
|
||||
"REGENERATE": "重新生成备份代码",
|
||||
"DISABLE_MFA": "禁用两步验证",
|
||||
"DISABLE_MFA_DESC": "从您的帐户中删除两步验证",
|
||||
"DISABLE_BUTTON": "禁用两步验证"
|
||||
},
|
||||
"DISABLE": {
|
||||
"TITLE": "禁用两步验证",
|
||||
"DESCRIPTION": "您需要输入您的密码和验证码来禁用两步验证。",
|
||||
"PASSWORD": "密码",
|
||||
"OTP_CODE": "验证码",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"BACKUP_CODE": "备份代码",
|
||||
"BACKUP_CODE_PLACEHOLDER": "输入您的备份代码",
|
||||
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
|
||||
"USE_OTP_CODE": "Use a verification code from your authenticator app",
|
||||
"CONFIRM": "禁用两步验证",
|
||||
"CANCEL": "取消",
|
||||
"SUCCESS": "两步验证已禁用",
|
||||
"ERROR": "禁用MFA失败。请检查您的凭据。"
|
||||
},
|
||||
"REGENERATE": {
|
||||
"TITLE": "重新生成备份代码",
|
||||
"DESCRIPTION": "这将作废您现有的备份代码并生成新的替代。输入您的验证码以继续。",
|
||||
"OTP_CODE": "验证码",
|
||||
"OTP_CODE_PLACEHOLDER": "000000",
|
||||
"CONFIRM": "生成新代码",
|
||||
"CANCEL": "取消",
|
||||
"NEW_CODES_TITLE": "新的备份码已生成",
|
||||
"NEW_CODES_DESC": "您旧的备份代码已失效。将这些新代码保存到一个安全位置。",
|
||||
"CODES_IMPORTANT": "重要:",
|
||||
"CODES_IMPORTANT_NOTE": " 每个代码只能使用一次。在关闭此窗口前保存它们。",
|
||||
"DOWNLOAD_CODES": "下载代码",
|
||||
"COPY_ALL_CODES": "复制全部代码",
|
||||
"CODES_SAVED": "我已保存我的代码",
|
||||
"SUCCESS": "已生成新的备份代码",
|
||||
"ERROR": "重新生成备份代码失败"
|
||||
}
|
||||
},
|
||||
"MFA_VERIFICATION": {
|
||||
"TITLE": "两步验证",
|
||||
"DESCRIPTION": "输入您的验证码以继续",
|
||||
"AUTHENTICATOR_APP": "身份验证器应用",
|
||||
"BACKUP_CODE": "备份代码",
|
||||
"ENTER_OTP_CODE": "从您的身份验证程序中输入6位数字代码",
|
||||
"ENTER_BACKUP_CODE": "输入您的备份代码",
|
||||
"BACKUP_CODE_PLACEHOLDER": "000000",
|
||||
"VERIFY_BUTTON": "验证",
|
||||
"TRY_ANOTHER_METHOD": "尝试另一种验证方法",
|
||||
"CANCEL_LOGIN": "取消并返回登录",
|
||||
"HELP_TEXT": "登录遇到困难吗?",
|
||||
"LEARN_MORE": "了解更多关于两步验证的信息",
|
||||
"HELP_MODAL": {
|
||||
"TITLE": "两步验证帮助",
|
||||
"AUTHENTICATOR_TITLE": "使用身份验证器应用程序",
|
||||
"AUTHENTICATOR_DESC": "打开你的身份验证器应用(Google Autenticator,Authy等),然后输入应用显示的6位数字",
|
||||
"BACKUP_TITLE": "使用备份代码",
|
||||
"BACKUP_DESC": "如果您无法访问身份验证器应用程序,你可以使用此前保存的备份代码替代,每个代码只能使用一次。",
|
||||
"CONTACT_TITLE": "需要更多帮助吗?",
|
||||
"CONTACT_DESC_CLOUD": "如果您无法访问身份验证器应用程序和备份代码,请联系Chatwoot 支持寻求帮助。",
|
||||
"CONTACT_DESC_SELF_HOSTED": "如果您无法访问身份验证器应用程序和备份代码,请联系您的管理员寻求帮助。"
|
||||
},
|
||||
"VERIFICATION_FAILED": "验证失败。请重试。"
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { parseBoolean } from '@chatwoot/utils';
|
||||
import UserProfilePicture from './UserProfilePicture.vue';
|
||||
import UserBasicDetails from './UserBasicDetails.vue';
|
||||
import MessageSignature from './MessageSignature.vue';
|
||||
@@ -19,7 +18,6 @@ import AudioNotifications from './AudioNotifications.vue';
|
||||
import SectionLayout from '../account/components/SectionLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AccessToken from './AccessToken.vue';
|
||||
import MfaSettingsCard from './MfaSettingsCard.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import {
|
||||
@@ -41,7 +39,6 @@ export default {
|
||||
NotificationPreferences,
|
||||
AudioNotifications,
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
BaseSettingsHeader,
|
||||
},
|
||||
setup() {
|
||||
@@ -100,9 +97,6 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
isMfaEnabled() {
|
||||
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.currentUserId) {
|
||||
@@ -299,14 +293,6 @@ export default {
|
||||
>
|
||||
<ChangePassword />
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
v-if="isMfaEnabled"
|
||||
with-border
|
||||
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
|
||||
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
|
||||
>
|
||||
<MfaSettingsCard />
|
||||
</SectionLayout>
|
||||
<Policy :permissions="audioNotificationPermissions">
|
||||
<SectionLayout
|
||||
with-border
|
||||
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mfaEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
backupCodes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['disableMfa', 'regenerateBackupCodes']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Dialog refs
|
||||
const disableDialogRef = ref(null);
|
||||
const regenerateDialogRef = ref(null);
|
||||
const backupCodesDialogRef = ref(null);
|
||||
|
||||
// Form values
|
||||
const disablePassword = ref('');
|
||||
const disableOtpCode = ref('');
|
||||
const disableBackupCode = ref('');
|
||||
const useBackupCodeToDisable = ref(false);
|
||||
const regenerateOtpCode = ref('');
|
||||
|
||||
// Utility functions
|
||||
const copyBackupCodes = async () => {
|
||||
const codesText = props.backupCodes.join('\n');
|
||||
await copyTextToClipboard(codesText);
|
||||
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
|
||||
const blob = new Blob([codesText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'chatwoot-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleDisableMfa = async () => {
|
||||
emit('disableMfa', {
|
||||
password: disablePassword.value,
|
||||
otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value,
|
||||
backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDisableMethod = () => {
|
||||
useBackupCodeToDisable.value = !useBackupCodeToDisable.value;
|
||||
disableOtpCode.value = '';
|
||||
disableBackupCode.value = '';
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
emit('regenerateBackupCodes', {
|
||||
otpCode: regenerateOtpCode.value,
|
||||
});
|
||||
};
|
||||
|
||||
// Methods exposed for parent component
|
||||
const resetDisableForm = () => {
|
||||
disablePassword.value = '';
|
||||
disableOtpCode.value = '';
|
||||
disableBackupCode.value = '';
|
||||
useBackupCodeToDisable.value = false;
|
||||
disableDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const resetRegenerateForm = () => {
|
||||
regenerateOtpCode.value = '';
|
||||
regenerateDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const showBackupCodesDialog = () => {
|
||||
backupCodesDialogRef.value?.open();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
resetDisableForm,
|
||||
resetRegenerateForm,
|
||||
showBackupCodesDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="mfaEnabled">
|
||||
<!-- Actions Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Regenerate Backup Codes -->
|
||||
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-key"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<h4 class="font-medium text-n-slate-12">
|
||||
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES') }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.MANAGEMENT.BACKUP_CODES_DESC') }}
|
||||
</p>
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
:label="$t('MFA_SETTINGS.MANAGEMENT.REGENERATE')"
|
||||
@click="regenerateDialogRef?.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disable MFA -->
|
||||
<div class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-5">
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole-open"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<h4 class="font-medium text-n-slate-12">
|
||||
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA') }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.MANAGEMENT.DISABLE_MFA_DESC') }}
|
||||
</p>
|
||||
<Button
|
||||
faded
|
||||
ruby
|
||||
:label="$t('MFA_SETTINGS.MANAGEMENT.DISABLE_BUTTON')"
|
||||
@click="disableDialogRef?.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disable MFA Dialog -->
|
||||
<Dialog
|
||||
ref="disableDialogRef"
|
||||
type="alert"
|
||||
:title="$t('MFA_SETTINGS.DISABLE.TITLE')"
|
||||
:description="$t('MFA_SETTINGS.DISABLE.DESCRIPTION')"
|
||||
:confirm-button-label="$t('MFA_SETTINGS.DISABLE.CONFIRM')"
|
||||
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
|
||||
@confirm="handleDisableMfa"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<Input
|
||||
v-model="disablePassword"
|
||||
type="password"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.PASSWORD')"
|
||||
/>
|
||||
<Input
|
||||
v-if="!useBackupCodeToDisable"
|
||||
v-model="disableOtpCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="disableBackupCode"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Button
|
||||
link
|
||||
sm
|
||||
type="button"
|
||||
:label="
|
||||
useBackupCodeToDisable
|
||||
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
|
||||
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
|
||||
"
|
||||
@click="toggleDisableMethod"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- Regenerate Backup Codes Dialog -->
|
||||
<Dialog
|
||||
ref="regenerateDialogRef"
|
||||
type="edit"
|
||||
:title="$t('MFA_SETTINGS.REGENERATE.TITLE')"
|
||||
:description="$t('MFA_SETTINGS.REGENERATE.DESCRIPTION')"
|
||||
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CONFIRM')"
|
||||
:cancel-button-label="$t('MFA_SETTINGS.DISABLE.CANCEL')"
|
||||
@confirm="handleRegenerateBackupCodes"
|
||||
>
|
||||
<Input
|
||||
v-model="regenerateOtpCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
:label="$t('MFA_SETTINGS.REGENERATE.OTP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.REGENERATE.OTP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<!-- Backup Codes Display Dialog -->
|
||||
<Dialog
|
||||
ref="backupCodesDialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_TITLE')"
|
||||
:description="$t('MFA_SETTINGS.REGENERATE.NEW_CODES_DESC')"
|
||||
:show-cancel-button="false"
|
||||
:confirm-button-label="$t('MFA_SETTINGS.REGENERATE.CODES_SAVED')"
|
||||
@confirm="backupCodesDialogRef?.close()"
|
||||
>
|
||||
<!-- Warning Alert -->
|
||||
<div
|
||||
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-alert-circle"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
|
||||
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
|
||||
>
|
||||
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
|
||||
<span
|
||||
v-for="(code, index) in backupCodes"
|
||||
:key="index"
|
||||
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
|
||||
>
|
||||
{{ code }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
outline
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
|
||||
@click="downloadBackupCodes"
|
||||
/>
|
||||
<Button
|
||||
outline
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-clipboard"
|
||||
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
|
||||
@click="copyBackupCodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -1,173 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { parseBoolean } from '@chatwoot/utils';
|
||||
import mfaAPI from 'dashboard/api/mfa';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MfaStatusCard from './MfaStatusCard.vue';
|
||||
import MfaSetupWizard from './MfaSetupWizard.vue';
|
||||
import MfaManagementActions from './MfaManagementActions.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// State
|
||||
const mfaEnabled = ref(false);
|
||||
const backupCodesGenerated = ref(false);
|
||||
const showSetup = ref(false);
|
||||
const provisioningUri = ref('');
|
||||
const qrCodeUrl = ref('');
|
||||
const secretKey = ref('');
|
||||
const backupCodes = ref([]);
|
||||
|
||||
// Component refs
|
||||
const setupWizardRef = ref(null);
|
||||
const managementActionsRef = ref(null);
|
||||
|
||||
// Load MFA status on mount
|
||||
onMounted(async () => {
|
||||
// Check if MFA is enabled globally
|
||||
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
|
||||
// Redirect to profile settings if MFA is disabled
|
||||
router.push({
|
||||
name: 'profile_settings_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await mfaAPI.get();
|
||||
mfaEnabled.value = response.data.enabled;
|
||||
backupCodesGenerated.value = response.data.backup_codes_generated;
|
||||
} catch (error) {
|
||||
// Handle error silently
|
||||
}
|
||||
});
|
||||
|
||||
// Start MFA setup
|
||||
const startMfaSetup = async () => {
|
||||
try {
|
||||
const response = await mfaAPI.enable();
|
||||
|
||||
// Store the provisioning URI
|
||||
provisioningUri.value =
|
||||
response.data.provisioning_uri || response.data.provisioning_url;
|
||||
|
||||
// Store QR code URL if provided by backend
|
||||
if (response.data.qr_code_url) {
|
||||
qrCodeUrl.value = response.data.qr_code_url;
|
||||
}
|
||||
|
||||
secretKey.value = response.data.secret;
|
||||
// Backup codes are now generated after verification, not during enable
|
||||
backupCodes.value = [];
|
||||
showSetup.value = true;
|
||||
} catch (error) {
|
||||
useAlert(t('MFA_SETTINGS.SETUP.ERROR_STARTING'));
|
||||
}
|
||||
};
|
||||
|
||||
// Verify OTP code
|
||||
const verifyCode = async verificationCode => {
|
||||
try {
|
||||
const response = await mfaAPI.verify(verificationCode);
|
||||
// Store backup codes returned from verification
|
||||
if (response.data.backup_codes) {
|
||||
backupCodes.value = response.data.backup_codes;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
setupWizardRef.value?.handleVerificationError(
|
||||
error.response?.data?.error || t('MFA_SETTINGS.SETUP.INVALID_CODE')
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Complete MFA setup
|
||||
const completeMfaSetup = () => {
|
||||
mfaEnabled.value = true;
|
||||
backupCodesGenerated.value = true;
|
||||
showSetup.value = false;
|
||||
useAlert(t('MFA_SETTINGS.SETUP.SUCCESS'));
|
||||
};
|
||||
|
||||
// Cancel setup
|
||||
const cancelSetup = () => {
|
||||
showSetup.value = false;
|
||||
};
|
||||
|
||||
// Disable MFA
|
||||
const disableMfa = async ({ password, otpCode, backupCode }) => {
|
||||
try {
|
||||
await mfaAPI.disable(password, { otpCode, backupCode });
|
||||
mfaEnabled.value = false;
|
||||
backupCodesGenerated.value = false;
|
||||
managementActionsRef.value?.resetDisableForm();
|
||||
useAlert(t('MFA_SETTINGS.DISABLE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('MFA_SETTINGS.DISABLE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// Regenerate backup codes
|
||||
const regenerateBackupCodes = async ({ otpCode }) => {
|
||||
try {
|
||||
const response = await mfaAPI.regenerateBackupCodes(otpCode);
|
||||
backupCodes.value = response.data.backup_codes;
|
||||
managementActionsRef.value?.resetRegenerateForm();
|
||||
managementActionsRef.value?.showBackupCodesDialog();
|
||||
useAlert(t('MFA_SETTINGS.REGENERATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('MFA_SETTINGS.REGENERATE.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid w-full">
|
||||
<BaseSettingsHeader
|
||||
:title="$t('MFA_SETTINGS.TITLE')"
|
||||
:description="$t('MFA_SETTINGS.SUBTITLE')"
|
||||
:back-button-label="$t('PROFILE_SETTINGS.TITLE')"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 w-full mt-4">
|
||||
<!-- MFA Status Card -->
|
||||
<MfaStatusCard
|
||||
:mfa-enabled="mfaEnabled"
|
||||
:show-setup="showSetup"
|
||||
@enable-mfa="startMfaSetup"
|
||||
/>
|
||||
|
||||
<!-- MFA Setup Wizard -->
|
||||
<MfaSetupWizard
|
||||
ref="setupWizardRef"
|
||||
:show-setup="showSetup"
|
||||
:mfa-enabled="mfaEnabled"
|
||||
:provisioning-uri="provisioningUri"
|
||||
:secret-key="secretKey"
|
||||
:backup-codes="backupCodes"
|
||||
:qr-code-url-prop="qrCodeUrl"
|
||||
@cancel="cancelSetup"
|
||||
@verify="verifyCode"
|
||||
@complete="completeMfaSetup"
|
||||
/>
|
||||
|
||||
<!-- MFA Management Actions -->
|
||||
<MfaManagementActions
|
||||
ref="managementActionsRef"
|
||||
:mfa-enabled="mfaEnabled"
|
||||
:backup-codes="backupCodes"
|
||||
@disable-mfa="disableMfa"
|
||||
@regenerate-backup-codes="regenerateBackupCodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const navigateToMfa = () => {
|
||||
router.push({
|
||||
name: 'profile_settings_mfa',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-background rounded-xl p-4 border border-n-slate-4">
|
||||
<div class="flex flex-col xs:flex-row items-center justify-between gap-4">
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<h5 class="text-heading-3 text-n-slate-12">
|
||||
{{ $t('MFA_SETTINGS.TITLE') }}
|
||||
</h5>
|
||||
</div>
|
||||
<p class="text-body-para text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
faded
|
||||
:label="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.MFA_BUTTON')"
|
||||
icon="i-lucide-settings"
|
||||
class="flex-shrink-0"
|
||||
@click="navigateToMfa"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-323
@@ -1,323 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import QRCode from 'qrcode';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
showSetup: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
mfaEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
provisioningUri: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
secretKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
backupCodes: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
qrCodeUrlProp: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cancel', 'verify', 'complete']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Local state
|
||||
const setupStep = ref('qr');
|
||||
const qrCodeUrl = ref('');
|
||||
const verificationCode = ref('');
|
||||
const verificationError = ref('');
|
||||
const backupCodesConfirmed = ref(false);
|
||||
|
||||
// Generate QR code from provisioning URI
|
||||
const generateQRCode = async provisioningUrl => {
|
||||
try {
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(provisioningUrl, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
},
|
||||
});
|
||||
return qrCodeDataUrl;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch for provisioning URI changes
|
||||
watch(
|
||||
() => props.provisioningUri,
|
||||
async newUri => {
|
||||
if (newUri) {
|
||||
qrCodeUrl.value = await generateQRCode(newUri);
|
||||
} else if (props.qrCodeUrlProp) {
|
||||
qrCodeUrl.value = props.qrCodeUrlProp;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const verifyCode = async () => {
|
||||
verificationError.value = '';
|
||||
try {
|
||||
emit('verify', verificationCode.value);
|
||||
setupStep.value = 'backup';
|
||||
verificationCode.value = '';
|
||||
} catch (error) {
|
||||
verificationError.value = t('MFA_SETTINGS.SETUP.INVALID_CODE');
|
||||
}
|
||||
};
|
||||
|
||||
const copySecret = async () => {
|
||||
await copyTextToClipboard(props.secretKey);
|
||||
useAlert(t('MFA_SETTINGS.SETUP.SECRET_COPIED'));
|
||||
};
|
||||
|
||||
const copyBackupCodes = async () => {
|
||||
const codesText = props.backupCodes.join('\n');
|
||||
await copyTextToClipboard(codesText);
|
||||
useAlert(t('MFA_SETTINGS.BACKUP.CODES_COPIED'));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const codesText = `Chatwoot Two-Factor Authentication Backup Codes\n\n${props.backupCodes.join('\n')}\n\nKeep these codes in a safe place.`;
|
||||
const blob = new Blob([codesText], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'chatwoot-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setupStep.value = 'qr';
|
||||
verificationCode.value = '';
|
||||
verificationError.value = '';
|
||||
backupCodesConfirmed.value = false;
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const completeMfaSetup = () => {
|
||||
setupStep.value = 'qr';
|
||||
backupCodesConfirmed.value = false;
|
||||
emit('complete');
|
||||
};
|
||||
|
||||
// Reset when showSetup changes
|
||||
watch(
|
||||
() => props.showSetup,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
setupStep.value = 'qr';
|
||||
verificationCode.value = '';
|
||||
verificationError.value = '';
|
||||
backupCodesConfirmed.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Handle verification error
|
||||
const handleVerificationError = error => {
|
||||
verificationError.value = error || t('MFA_SETTINGS.SETUP.INVALID_CODE');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
handleVerificationError,
|
||||
setupStep,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showSetup && !mfaEnabled">
|
||||
<!-- Step 1: QR Code -->
|
||||
<div v-if="setupStep === 'qr'" class="space-y-6">
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-10 flex flex-col gap-4"
|
||||
>
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_SETTINGS.SETUP.STEP1_TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.SETUP.STEP1_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="bg-n-background p-4 rounded-lg outline outline-1 outline-n-weak"
|
||||
>
|
||||
<img
|
||||
v-if="qrCodeUrl"
|
||||
:src="qrCodeUrl"
|
||||
alt="MFA QR Code"
|
||||
class="w-48 h-48 dark:invert-0"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-48 h-48 flex items-center justify-center bg-n-slate-2 dark:bg-n-slate-3"
|
||||
>
|
||||
<span class="text-n-slate-10">
|
||||
{{ $t('MFA_SETTINGS.SETUP.LOADING_QR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="border border-n-slate-4 rounded-lg">
|
||||
<summary
|
||||
class="px-4 py-3 cursor-pointer hover:bg-n-slate-2 dark:hover:bg-n-slate-3 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('MFA_SETTINGS.SETUP.MANUAL_ENTRY') }}
|
||||
</summary>
|
||||
<div class="px-4 pb-4">
|
||||
<label class="block text-xs text-n-slate-10 mb-2">
|
||||
{{ $t('MFA_SETTINGS.SETUP.SECRET_KEY') }}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input :model-value="secretKey" readonly class="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:label="$t('MFA_SETTINGS.SETUP.COPY')"
|
||||
@click="copySecret"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="flex flex-col items-start gap-3 w-full">
|
||||
<Input
|
||||
v-model="verificationCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
:label="$t('MFA_SETTINGS.SETUP.ENTER_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.SETUP.ENTER_CODE_PLACEHOLDER')"
|
||||
:message="verificationError"
|
||||
:message-type="verificationError ? 'error' : 'info'"
|
||||
class="w-full"
|
||||
@keyup.enter="verifyCode"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 mt-1 w-full justify-between">
|
||||
<Button
|
||||
faded
|
||||
color="slate"
|
||||
class="flex-1"
|
||||
:label="$t('MFA_SETTINGS.SETUP.CANCEL')"
|
||||
@click="cancelSetup"
|
||||
/>
|
||||
<Button
|
||||
class="flex-1"
|
||||
:disabled="verificationCode.length !== 6"
|
||||
:label="$t('MFA_SETTINGS.SETUP.VERIFY_BUTTON')"
|
||||
@click="verifyCode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Backup Codes -->
|
||||
<div v-if="setupStep === 'backup'" class="space-y-6">
|
||||
<div class="text-start">
|
||||
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_SETTINGS.BACKUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.BACKUP.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Warning Alert -->
|
||||
<div
|
||||
class="flex items-start gap-2 p-4 bg-n-solid-1 outline outline-n-weak rounded-xl outline-1"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-alert-circle"
|
||||
class="size-4 text-n-slate-10 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<strong>{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT') }}</strong>
|
||||
{{ $t('MFA_SETTINGS.BACKUP.IMPORTANT_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Grid -->
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline flex flex-col gap-6 p-6"
|
||||
>
|
||||
<div class="grid grid-cols-2 xs:grid-cols-4 sm:grid-cols-5 gap-3">
|
||||
<span
|
||||
v-for="(code, index) in backupCodes"
|
||||
:key="index"
|
||||
class="px-1 py-2 font-mono text-base text-center text-n-slate-12"
|
||||
>
|
||||
{{ code }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
outline
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-download"
|
||||
:label="$t('MFA_SETTINGS.BACKUP.DOWNLOAD')"
|
||||
@click="downloadBackupCodes"
|
||||
/>
|
||||
<Button
|
||||
outline
|
||||
slate
|
||||
sm
|
||||
icon="i-lucide-clipboard"
|
||||
:label="$t('MFA_SETTINGS.BACKUP.COPY_ALL')"
|
||||
@click="copyBackupCodes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="backupCodesConfirmed"
|
||||
type="checkbox"
|
||||
class="mt-1 rounded border-n-slate-4 text-n-blue-9 focus:ring-n-blue-8"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.BACKUP.CONFIRM') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
:disabled="!backupCodesConfirmed"
|
||||
:label="$t('MFA_SETTINGS.BACKUP.COMPLETE_SETUP')"
|
||||
@click="completeMfaSetup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
mfaEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
showSetup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['enableMfa']);
|
||||
|
||||
const startSetup = () => {
|
||||
emit('enableMfa');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!mfaEnabled && !showSetup" class="space-y-6">
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-lg p-6 outline outline-n-weak outline-1 text-center"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-8 text-n-slate-10 mx-auto mb-4 block"
|
||||
/>
|
||||
<h3 class="text-lg font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY') }}
|
||||
</h3>
|
||||
<p class="text-sm text-n-slate-11 mb-6 max-w-md mx-auto">
|
||||
{{ $t('MFA_SETTINGS.ENHANCE_SECURITY_DESC') }}
|
||||
</p>
|
||||
<Button
|
||||
icon="i-lucide-settings"
|
||||
:label="$t('MFA_SETTINGS.ENABLE_BUTTON')"
|
||||
@click="startSetup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="mfaEnabled && !showSetup">
|
||||
<div
|
||||
class="bg-n-solid-1 rounded-xl outline-1 outline-n-weak outline p-4 flex-1 flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('MFA_SETTINGS.STATUS_ENABLED') }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_SETTINGS.STATUS_ENABLED_DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,7 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { parseBoolean } from '@chatwoot/utils';
|
||||
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
import MfaSettings from './MfaSettings.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -23,23 +21,6 @@ export default {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'mfa',
|
||||
name: 'profile_settings_mfa',
|
||||
component: MfaSettings,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'custom_role'],
|
||||
},
|
||||
beforeEnter: (to, from, next) => {
|
||||
// Check if MFA is enabled globally
|
||||
if (!parseBoolean(window.chatwootConfig?.isMfaEnabled)) {
|
||||
// Redirect to profile settings if MFA is disabled
|
||||
next({ name: 'profile_settings_index' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import SamlSettings from './components/SamlSettings.vue';
|
||||
import SamlPaywall from './components/SamlPaywall.vue';
|
||||
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
const { shouldShow, shouldShowPaywall } = usePolicy();
|
||||
|
||||
const allowedLoginMethods = computed(
|
||||
() => window.chatwootConfig.allowedLoginMethods || ['email']
|
||||
);
|
||||
|
||||
const isSamlSsoEnabled = computed(() =>
|
||||
allowedLoginMethods.value.includes('saml')
|
||||
);
|
||||
|
||||
const shouldShowSaml = computed(() => {
|
||||
const hasPermission = shouldShow(
|
||||
FEATURE_FLAGS.SAML,
|
||||
['administrator'],
|
||||
[INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE]
|
||||
);
|
||||
return hasPermission && isSamlSsoEnabled.value;
|
||||
});
|
||||
|
||||
const showPaywall = computed(() => shouldShowPaywall('saml'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :loading-message="$t('ATTRIBUTES_MGMT.LOADING')">
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('SECURITY_SETTINGS.TITLE')"
|
||||
:description="$t('SECURITY_SETTINGS.DESCRIPTION')"
|
||||
:link-text="$t('SECURITY_SETTINGS.LINK_TEXT')"
|
||||
feature-name="saml"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<SamlPaywall v-if="showPaywall" />
|
||||
<SamlSettings v-else-if="shouldShowSaml" />
|
||||
<div v-else class="mt-6 text-sm text-slate-600">
|
||||
{{ $t('SECURITY_SETTINGS.SAML_DISABLED_MESSAGE') }}
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 mb-5 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-n-solid-2 transition-colors"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<h4 class="font-medium text-n-slate-12">
|
||||
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.TITLE') }}
|
||||
</h4>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="transition-[height] duration-200 ease-in-out overflow-hidden"
|
||||
:class="isExpanded ? 'h-auto' : 'h-0'"
|
||||
>
|
||||
<div class="px-4 pb-3">
|
||||
<p class="text-n-slate-11 mb-2">
|
||||
{{ t('SECURITY_SETTINGS.SAML.ATTRIBUTE_MAPPING.DESCRIPTION') }}
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<ul class="list-none text-n-slate-12 space-y-1">
|
||||
<li><code class="px-1 rounded bg-n-slate-3">email</code></li>
|
||||
<li><code class="px-1 rounded bg-n-slate-3">first_name</code></li>
|
||||
<li><code class="px-1 rounded bg-n-slate-3">last_name</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
fingerprint: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spEntityId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const acsUrl = computed(() => {
|
||||
const currentHost = window.location.origin;
|
||||
return `${currentHost}/omniauth/saml/callback?account_id=${accountId.value}`;
|
||||
});
|
||||
|
||||
const allInfoItems = computed(() => [
|
||||
{
|
||||
key: 'ACS_URL',
|
||||
label: t('SECURITY_SETTINGS.SAML.ACS_URL.LABEL'),
|
||||
value: acsUrl.value,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.ACS_URL.TOOLTIP'),
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
key: 'SP_ENTITY_ID',
|
||||
label: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.LABEL'),
|
||||
value: props.spEntityId,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.SP_ENTITY_ID.TOOLTIP'),
|
||||
show: !!props.spEntityId,
|
||||
},
|
||||
{
|
||||
key: 'FINGERPRINT',
|
||||
label: t('SECURITY_SETTINGS.SAML.FINGERPRINT.LABEL'),
|
||||
value: props.fingerprint,
|
||||
tooltip: t('SECURITY_SETTINGS.SAML.FINGERPRINT.TOOLTIP'),
|
||||
show: !!props.fingerprint,
|
||||
},
|
||||
]);
|
||||
|
||||
const visibleInfoItems = computed(() =>
|
||||
allInfoItems.value.filter(item => item.show)
|
||||
);
|
||||
|
||||
const handleCopy = async text => {
|
||||
await copyTextToClipboard(text);
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('SECURITY_SETTINGS.SAML.INFO_SECTION.TITLE') }}
|
||||
</h3>
|
||||
<i
|
||||
v-tooltip.top="t('SECURITY_SETTINGS.SAML.INFO_SECTION.TOOLTIP')"
|
||||
class="i-lucide-info text-n-slate-10 w-4 h-4 cursor-help"
|
||||
/>
|
||||
</div>
|
||||
<section
|
||||
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
|
||||
v-for="item in visibleInfoItems"
|
||||
:key="item.key"
|
||||
class="ps-4 pe-1 py-1 flex justify-between items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-n-slate-11 w-32 flex items-center gap-1">
|
||||
{{ item.label }}
|
||||
<i
|
||||
v-tooltip.top="item.tooltip"
|
||||
class="i-lucide-info text-n-slate-9 w-3 h-3 cursor-help"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex-1">{{ item.value }}</span>
|
||||
</div>
|
||||
<NextButton
|
||||
type="button"
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-copy"
|
||||
@click="handleCopy(item.value)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isSuperAdmin = computed(() => {
|
||||
return currentUser.value.type === 'SuperAdmin';
|
||||
});
|
||||
const { accountId, isOnChatwootCloud } = useAccount();
|
||||
|
||||
const i18nKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
|
||||
);
|
||||
const openBilling = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full max-w-5xl mx-auto h-full max-h-[28rem] grid place-content-center"
|
||||
>
|
||||
<BasePaywallModal
|
||||
class="mx-auto"
|
||||
feature-prefix="SECURITY_SETTINGS.SAML"
|
||||
:i18n-key="i18nKey"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@upgrade="openBilling"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-253
@@ -1,253 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import samlSettingsAPI from 'dashboard/api/samlSettings';
|
||||
|
||||
import SectionLayout from '../../account/components/SectionLayout.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import TextInput from 'next/input/Input.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import SamlInfoSection from './SamlInfoSection.vue';
|
||||
import SamlAttributeMap from './SamlAttributeMap.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const id = ref(null);
|
||||
const fingerprint = ref('');
|
||||
const spEntityId = ref('');
|
||||
const isEnabled = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const isLoading = ref(true);
|
||||
|
||||
const formState = reactive({
|
||||
ssoUrl: '',
|
||||
certificate: '',
|
||||
idpEntityId: '',
|
||||
});
|
||||
|
||||
const validations = {
|
||||
ssoUrl: { required },
|
||||
certificate: { required },
|
||||
idpEntityId: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, formState);
|
||||
|
||||
const hasFeature = computed(() => isCloudFeatureEnabled('saml'));
|
||||
|
||||
const ssoUrlError = computed(() =>
|
||||
v$.value.ssoUrl.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.SSO_URL_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const certificateError = computed(() =>
|
||||
v$.value.certificate.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.CERTIFICATE_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const idpEntityIdError = computed(() =>
|
||||
v$.value.idpEntityId.$error
|
||||
? t('SECURITY_SETTINGS.SAML.VALIDATION.IDP_ENTITY_ID_ERROR')
|
||||
: ''
|
||||
);
|
||||
|
||||
const loadSamlSettings = async () => {
|
||||
if (!hasFeature.value) return;
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await samlSettingsAPI.get();
|
||||
const settings = response.data;
|
||||
|
||||
if (settings.sso_url) {
|
||||
id.value = settings.id;
|
||||
formState.ssoUrl = settings.sso_url;
|
||||
formState.certificate = settings.certificate || '';
|
||||
spEntityId.value = settings.sp_entity_id || '';
|
||||
formState.idpEntityId = settings.idp_entity_id || '';
|
||||
fingerprint.value = settings.fingerprint || '';
|
||||
isEnabled.value = formState.ssoUrl !== '';
|
||||
}
|
||||
} catch (error) {
|
||||
// If no settings exist (404), that's expected - just keep defaults
|
||||
if (error.response?.status !== 404) {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR_LOADING'));
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSamlSettings = async settings => {
|
||||
try {
|
||||
isSubmitting.value = true;
|
||||
|
||||
if (isEnabled.value && formState.ssoUrl) {
|
||||
// Create or update settings based on existing id
|
||||
let response;
|
||||
if (id.value) {
|
||||
response = await samlSettingsAPI.update(settings);
|
||||
} else {
|
||||
response = await samlSettingsAPI.create(settings);
|
||||
}
|
||||
|
||||
// Update local state with response data including fingerprint and id
|
||||
if (response?.data) {
|
||||
id.value = response.data.id;
|
||||
fingerprint.value = response.data.fingerprint || '';
|
||||
spEntityId.value = response.data.sp_entity_id || '';
|
||||
}
|
||||
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.SUCCESS'));
|
||||
} else {
|
||||
// Disable/delete settings
|
||||
await samlSettingsAPI.delete();
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.DISABLED'));
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle backend validation errors
|
||||
if (error.response?.data?.errors) {
|
||||
const errorMessages = error.response.data.errors;
|
||||
const firstError = Array.isArray(errorMessages)
|
||||
? errorMessages[0]
|
||||
: errorMessages;
|
||||
useAlert(firstError);
|
||||
} else {
|
||||
useAlert(t('SECURITY_SETTINGS.SAML.API.ERROR'));
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
|
||||
const settings = {
|
||||
sso_url: formState.ssoUrl,
|
||||
certificate: formState.certificate,
|
||||
idp_entity_id: formState.idpEntityId,
|
||||
role_mappings: {},
|
||||
};
|
||||
|
||||
await saveSamlSettings(settings);
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
id.value = null;
|
||||
formState.ssoUrl = '';
|
||||
formState.certificate = '';
|
||||
spEntityId.value = '';
|
||||
formState.idpEntityId = '';
|
||||
fingerprint.value = '';
|
||||
|
||||
// the empty save will delete the SAML settings item
|
||||
await saveSamlSettings({});
|
||||
};
|
||||
|
||||
const toggleSaml = async () => {
|
||||
if (!isEnabled.value) {
|
||||
await handleDisable();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadSamlSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
|
||||
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
|
||||
beta
|
||||
:hide-content="!hasFeature || !isEnabled || isLoading"
|
||||
class="max-w-2xl ltr:mr-auto rtl:ml-auto"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch
|
||||
v-model="isEnabled"
|
||||
:disabled="isLoading"
|
||||
@change="toggleSaml"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SamlInfoSection
|
||||
class="mb-5"
|
||||
:fingerprint="fingerprint"
|
||||
:sp-entity-id="spEntityId"
|
||||
/>
|
||||
<SamlAttributeMap class="mb-5" />
|
||||
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
name="ssoUrl"
|
||||
:label="t('SECURITY_SETTINGS.SAML.SSO_URL.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.SSO_URL.HELP')"
|
||||
:has-error="v$.ssoUrl.$error"
|
||||
:error-message="ssoUrlError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.ssoUrl"
|
||||
class="w-full"
|
||||
type="url"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.SSO_URL.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="idpEntityId"
|
||||
:label="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.HELP')"
|
||||
:has-error="v$.idpEntityId.$error"
|
||||
:error-message="idpEntityIdError"
|
||||
required
|
||||
>
|
||||
<TextInput
|
||||
v-model="formState.idpEntityId"
|
||||
class="w-full"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.IDP_ENTITY_ID.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<WithLabel
|
||||
name="certificate"
|
||||
:label="t('SECURITY_SETTINGS.SAML.CERTIFICATE.LABEL')"
|
||||
:help-message="t('SECURITY_SETTINGS.SAML.CERTIFICATE.HELP')"
|
||||
:has-error="v$.certificate.$error"
|
||||
:error-message="certificateError"
|
||||
required
|
||||
>
|
||||
<TextArea
|
||||
v-model="formState.certificate"
|
||||
class="w-full"
|
||||
rows="8"
|
||||
:placeholder="t('SECURITY_SETTINGS.SAML.CERTIFICATE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('SECURITY_SETTINGS.SAML.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
+1
-22
@@ -1,8 +1,5 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -10,10 +7,6 @@ export default {
|
||||
path: frontendURL('accounts/:accountId/settings/security'),
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
@@ -21,21 +14,7 @@ export default {
|
||||
icon: 'i-lucide-shield',
|
||||
showNewButton: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'security_settings_index',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
featureFlag: FEATURE_FLAGS.SAML,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -12,38 +12,15 @@ export const login = async ({
|
||||
ssoConversationId,
|
||||
...credentials
|
||||
}) => {
|
||||
try {
|
||||
const response = await wootAPI.post('auth/sign_in', credentials);
|
||||
|
||||
// Check if MFA is required
|
||||
if (response.status === 206 && response.data.mfa_required) {
|
||||
// Return MFA data instead of throwing error
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
|
||||
setAuthCredentials(response);
|
||||
clearLocalStorageOnLogout();
|
||||
window.location = getLoginRedirectURL({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Check if it's an MFA required response
|
||||
if (error.response?.status === 206 && error.response?.data?.mfa_required) {
|
||||
return {
|
||||
mfaRequired: true,
|
||||
mfaToken: error.response.data.mfa_token,
|
||||
};
|
||||
}
|
||||
const loginError = new Error(parseAPIErrorResponse(error));
|
||||
loginError.errorCode = error.response?.data?.error_code;
|
||||
throw loginError;
|
||||
}
|
||||
const response = await wootAPI.post('auth/sign_in', credentials);
|
||||
setAuthCredentials(response);
|
||||
clearLocalStorageOnLogout();
|
||||
window.location = getLoginRedirectURL({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
user: response.data.data,
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('#validateRouteAccess', () => {
|
||||
|
||||
it('allows routes that were previously restricted to enterprise installs', () => {
|
||||
validateRouteAccess(
|
||||
{ name: 'saml_login', meta: { requireEnterprise: true } },
|
||||
{ name: 'reset_password', meta: { requireEnterprise: true } },
|
||||
next
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
|
||||
@@ -10,17 +10,13 @@ import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
// components
|
||||
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MfaVerification from 'dashboard/components/auth/MfaVerification.vue';
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
'no-account-found': 'LOGIN.OAUTH.NO_ACCOUNT_FOUND',
|
||||
'business-account-only': 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY',
|
||||
'saml-authentication-failed': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
'saml-not-enabled': 'LOGIN.SAML.API.ERROR_MESSAGE',
|
||||
};
|
||||
|
||||
const IMPERSONATION_URL_SEARCH_KEY = 'impersonation';
|
||||
@@ -29,11 +25,9 @@ const USER_NOT_CONFIRMED_ERROR_CODE = 'user_not_confirmed';
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
GoogleOAuthButton,
|
||||
Spinner,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
@@ -50,8 +44,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
// properties that will be used in it
|
||||
credentials: {
|
||||
email: '',
|
||||
password: '',
|
||||
@@ -62,8 +54,6 @@ export default {
|
||||
hasErrored: false,
|
||||
},
|
||||
error: '',
|
||||
mfaRequired: false,
|
||||
mfaToken: null,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@@ -79,71 +69,32 @@ export default {
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
allowedLoginMethods() {
|
||||
return window.chatwootConfig.allowedLoginMethods || ['email'];
|
||||
},
|
||||
showGoogleOAuth() {
|
||||
return (
|
||||
this.allowedLoginMethods.includes('google_oauth') &&
|
||||
Boolean(window.chatwootConfig.googleOAuthClientId)
|
||||
);
|
||||
},
|
||||
showSamlLogin() {
|
||||
return this.allowedLoginMethods.includes('saml');
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.ssoAuthToken) {
|
||||
this.submitLogin();
|
||||
}
|
||||
if (this.authError) {
|
||||
const messageKey = ERROR_MESSAGES[this.authError] ?? 'LOGIN.API.UNAUTH';
|
||||
// Use a method to get the translated text to avoid dynamic key warning
|
||||
const translatedMessage = this.getTranslatedMessage(messageKey);
|
||||
useAlert(translatedMessage);
|
||||
// wait for idle state
|
||||
this.showAlertMessage(this.$t('LOGIN.API.UNAUTH'));
|
||||
this.requestIdleCallbackPolyfill(() => {
|
||||
// Remove the error query param from the url
|
||||
const { query } = this.$route;
|
||||
this.$router.replace({ query: { ...query, error: undefined } });
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getTranslatedMessage(key) {
|
||||
// Avoid dynamic key warning by handling each case explicitly
|
||||
switch (key) {
|
||||
case 'LOGIN.OAUTH.NO_ACCOUNT_FOUND':
|
||||
return this.$t('LOGIN.OAUTH.NO_ACCOUNT_FOUND');
|
||||
case 'LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY':
|
||||
return this.$t('LOGIN.OAUTH.BUSINESS_ACCOUNTS_ONLY');
|
||||
case 'LOGIN.API.UNAUTH':
|
||||
default:
|
||||
return this.$t('LOGIN.API.UNAUTH');
|
||||
}
|
||||
},
|
||||
// TODO: Remove this when Safari gets wider support
|
||||
// Ref: https://caniuse.com/requestidlecallback
|
||||
//
|
||||
requestIdleCallbackPolyfill(callback) {
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(callback);
|
||||
} else {
|
||||
// Fallback for safari
|
||||
// Using a delay of 0 allows the callback to be executed asynchronously
|
||||
// in the next available event loop iteration, similar to requestIdleCallback
|
||||
setTimeout(callback, 0);
|
||||
}
|
||||
},
|
||||
showAlertMessage(message) {
|
||||
// Reset loading, current selected agent
|
||||
this.loginApi.showLoading = false;
|
||||
this.loginApi.message = message;
|
||||
useAlert(this.loginApi.message);
|
||||
},
|
||||
handleImpersonation() {
|
||||
// Detects impersonation mode via URL and sets a session flag to prevent user settings changes during impersonation.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const impersonation = urlParams.get(IMPERSONATION_URL_SEARCH_KEY);
|
||||
if (impersonation) {
|
||||
@@ -165,15 +116,7 @@ export default {
|
||||
};
|
||||
|
||||
login(credentials)
|
||||
.then(result => {
|
||||
// Check if MFA is required
|
||||
if (result?.mfaRequired) {
|
||||
this.loginApi.showLoading = false;
|
||||
this.mfaRequired = true;
|
||||
this.mfaToken = result.mfaToken;
|
||||
return;
|
||||
}
|
||||
|
||||
.then(() => {
|
||||
this.handleImpersonation();
|
||||
this.showAlertMessage(this.$t('LOGIN.API.SUCCESS_MESSAGE'));
|
||||
})
|
||||
@@ -187,7 +130,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset URL Params if the authentication is invalid
|
||||
if (this.email) {
|
||||
window.location = '/app/login';
|
||||
}
|
||||
@@ -205,17 +147,6 @@ export default {
|
||||
|
||||
this.submitLogin();
|
||||
},
|
||||
handleMfaVerified() {
|
||||
// MFA verification successful, continue with login
|
||||
this.handleImpersonation();
|
||||
window.location = '/app';
|
||||
},
|
||||
handleMfaCancel() {
|
||||
// User cancelled MFA, reset state
|
||||
this.mfaRequired = false;
|
||||
this.mfaToken = null;
|
||||
this.credentials.password = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -224,47 +155,15 @@ export default {
|
||||
<main
|
||||
class="flex flex-col items-center justify-center w-full min-h-screen bg-n-brand/5 dark:bg-n-background sm:px-6 lg:px-8"
|
||||
>
|
||||
<!-- MFA Verification Section -->
|
||||
<section v-if="mfaRequired" class="mt-11">
|
||||
<MfaVerification
|
||||
:mfa-token="mfaToken"
|
||||
@verified="handleMfaVerified"
|
||||
@cancel="handleMfaCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Regular Login Section -->
|
||||
<section
|
||||
v-else
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'mb-8 mt-15': !showGoogleOAuth,
|
||||
'mb-8 mt-15': true,
|
||||
'animate-wiggle': loginApi.hasErrored,
|
||||
}"
|
||||
>
|
||||
<div v-if="!email">
|
||||
<div class="flex flex-col gap-4">
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth" />
|
||||
<div v-if="showSamlLogin" class="text-center">
|
||||
<router-link
|
||||
to="/app/login/sso"
|
||||
class="inline-flex justify-center w-full px-4 py-3 items-center bg-n-background dark:bg-n-solid-3 rounded-md shadow-sm ring-1 ring-inset ring-n-container dark:ring-n-container focus:outline-offset-0 hover:bg-n-alpha-2 dark:hover:bg-n-alpha-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
<span class="ml-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('LOGIN.SAML.LABEL') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<SimpleDivider
|
||||
v-if="showGoogleOAuth || showSamlLogin"
|
||||
:label="$t('COMMON.OR')"
|
||||
class="uppercase"
|
||||
/>
|
||||
</div>
|
||||
<form class="space-y-5" @submit.prevent="submitFormLogin">
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, nextTick, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
// components
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
authError: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: 'web',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const credentials = ref({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const loginApi = ref({
|
||||
showLoading: false,
|
||||
hasErrored: false,
|
||||
});
|
||||
|
||||
const handleAuthError = () => {
|
||||
if (!props.authError) {
|
||||
return;
|
||||
}
|
||||
|
||||
const translatedMessage = t('LOGIN.SAML.API.ERROR_MESSAGE');
|
||||
useAlert(translatedMessage);
|
||||
loginApi.value.hasErrored = true;
|
||||
};
|
||||
|
||||
const validations = {
|
||||
credentials: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, { credentials });
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const csrfToken = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
csrfToken.value =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || '';
|
||||
|
||||
await nextTick(handleAuthError);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
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">
|
||||
{{ t('LOGIN.SAML.TITLE') }}
|
||||
</h2>
|
||||
</section>
|
||||
<section
|
||||
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
:class="{
|
||||
'animate-wiggle': loginApi.hasErrored,
|
||||
}"
|
||||
>
|
||||
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
name="email"
|
||||
type="text"
|
||||
:tabindex="1"
|
||||
required
|
||||
:label="t('LOGIN.SAML.WORK_EMAIL.LABEL')"
|
||||
:placeholder="t('LOGIN.SAML.WORK_EMAIL.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.email.$error"
|
||||
@input="v$.credentials.email.$touch"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
class="h-0"
|
||||
name="authenticity_token"
|
||||
:value="csrfToken"
|
||||
/>
|
||||
<input type="hidden" class="h-0" name="target" :value="target" />
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
class="w-full"
|
||||
:tabindex="2"
|
||||
:label="t('LOGIN.SAML.SUBMIT')"
|
||||
:disabled="loginApi.showLoading"
|
||||
:is-loading="loginApi.showLoading"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
<p class="mt-6 text-sm text-center text-n-slate-11">
|
||||
<router-link to="/app/login" class="text-link text-n-brand">
|
||||
{{ t('LOGIN.SAML.BACK_TO_LOGIN') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Login from './login/Index.vue';
|
||||
import SamlLogin from './login/Saml.vue';
|
||||
import ResetPassword from './auth/reset/password/Index.vue';
|
||||
import Confirmation from './auth/confirmation/Index.vue';
|
||||
import VerifyEmail from './auth/verify-email/Index.vue';
|
||||
@@ -21,16 +20,6 @@ export default [
|
||||
authError: route.query.error,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('login/sso'),
|
||||
name: 'sso_login',
|
||||
component: SamlLogin,
|
||||
meta: { requireEnterprise: true },
|
||||
props: route => ({
|
||||
authError: route.query.error,
|
||||
target: route.query.target,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/confirmation'),
|
||||
name: 'auth_confirmation',
|
||||
|
||||
Reference in New Issue
Block a user