feat: SAML feedback changes [CW-5666] (#12511)

This commit is contained in:
Shivam Mishra
2025-09-24 16:07:07 +05:30
committed by GitHub
parent eadbddaa9f
commit d3cd647e49
18 changed files with 116 additions and 78 deletions

View File

@@ -49,4 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2, FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
]; ];

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search", "PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found" "EMPTY_STATE": "No results found"
}, },
"CLOSE": "Close" "CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
} }
} }

View File

@@ -34,7 +34,7 @@
}, },
"SUBMIT": "Continue with SSO", "SUBMIT": "Continue with SSO",
"API": { "API": {
"ERROR_MESSAGE": "SSO authentication failed" "ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
} }
} }
} }

View File

@@ -1,10 +1,14 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
defineProps({ defineProps({
title: { type: String, required: true }, title: { type: String, required: true },
description: { type: String, required: true }, description: { type: String, required: true },
withBorder: { type: Boolean, default: false }, withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false }, hideContent: { type: Boolean, default: false },
beta: { type: Boolean, default: false },
}); });
const { t } = useI18n();
</script> </script>
<template> <template>
@@ -17,8 +21,15 @@ defineProps({
> >
<header class="grid grid-cols-4"> <header class="grid grid-cols-4">
<div class="col-span-3"> <div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12"> <h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<slot name="title">{{ title }}</slot> <slot name="title">{{ title }}</slot>
<div
v-if="beta"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.BETA') }}
</div>
</h4> </h4>
<p class="text-n-slate-11 text-sm mt-2"> <p class="text-n-slate-11 text-sm mt-2">
<slot name="description">{{ description }}</slot> <slot name="description">{{ description }}</slot>

View File

@@ -30,6 +30,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
provider: {
type: String,
default: '',
},
customRoleId: { customRoleId: {
type: Number, type: Number,
default: null, default: null,
@@ -203,6 +207,7 @@ const resetPassword = async () => {
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2"> <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"> <div class="w-[50%] ltr:text-left rtl:text-right">
<Button <Button
v-if="provider !== 'saml'"
ghost ghost
type="button" type="button"
icon="i-lucide-lock-keyhole" icon="i-lucide-lock-keyhole"

View File

@@ -261,6 +261,7 @@ const confirmDeletion = () => {
v-if="showEditPopup" v-if="showEditPopup"
:id="currentAgent.id" :id="currentAgent.id"
:name="currentAgent.name" :name="currentAgent.name"
:provider="currentAgent.provider"
:type="currentAgent.role" :type="currentAgent.role"
:email="currentAgent.email" :email="currentAgent.email"
:availability="currentAgent.availability_status" :availability="currentAgent.availability_status"

View File

@@ -171,6 +171,7 @@ onMounted(() => {
<SectionLayout <SectionLayout
:title="t('SECURITY_SETTINGS.SAML.TITLE')" :title="t('SECURITY_SETTINGS.SAML.TITLE')"
:description="t('SECURITY_SETTINGS.SAML.NOTE')" :description="t('SECURITY_SETTINGS.SAML.NOTE')"
beta
:hide-content="!hasFeature || !isEnabled || isLoading" :hide-content="!hasFeature || !isEnabled || isLoading"
> >
<template #headerActions> <template #headerActions>

View File

@@ -5,6 +5,10 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
bg: {
type: String,
default: 'bg-white dark:bg-n-solid-2',
},
}, },
}; };
</script> </script>
@@ -15,7 +19,7 @@ export default {
<div class="w-full border-t border-n-strong" /> <div class="w-full border-t border-n-strong" />
</div> </div>
<div v-if="label" class="relative flex justify-center text-sm"> <div v-if="label" class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-n-solid-2 px-2 text-n-slate-10"> <span class="px-2 text-n-slate-10" :class="bg">
{{ label }} {{ label }}
</span> </span>
</div> </div>

View File

@@ -1,9 +1,8 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import GoogleOAuthButton from './Button.vue'; import GoogleOAuthButton from './Button.vue';
function getWrapper(showSeparator) { function getWrapper() {
return shallowMount(GoogleOAuthButton, { return shallowMount(GoogleOAuthButton, {
propsData: { showSeparator: showSeparator },
mocks: { $t: text => text }, mocks: { $t: text => text },
}); });
} }
@@ -20,16 +19,6 @@ describe('GoogleOAuthButton.vue', () => {
window.chatwootConfig = {}; window.chatwootConfig = {};
}); });
it('renders the OR separator if showSeparator is true', () => {
const wrapper = getWrapper(true);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
});
it('does not render the OR separator if showSeparator is false', () => {
const wrapper = getWrapper(false);
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(false);
});
it('generates the correct Google Auth URL', () => { it('generates the correct Google Auth URL', () => {
const wrapper = getWrapper(); const wrapper = getWrapper();
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl()); const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
@@ -42,7 +31,5 @@ describe('GoogleOAuthButton.vue', () => {
); );
expect(params.get('response_type')).toBe('code'); expect(params.get('response_type')).toBe('code');
expect(params.get('scope')).toBe('email profile'); expect(params.get('scope')).toBe('email profile');
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
}); });
}); });

View File

@@ -1,16 +1,5 @@
<script> <script>
import SimpleDivider from '../Divider/SimpleDivider.vue';
export default { export default {
components: {
SimpleDivider,
},
props: {
showSeparator: {
type: Boolean,
default: true,
},
},
methods: { methods: {
getGoogleAuthUrl() { getGoogleAuthUrl() {
// Ideally a request to /auth/google_oauth2 should be made // Ideally a request to /auth/google_oauth2 should be made
@@ -52,11 +41,5 @@ export default {
{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }} {{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}
</span> </span>
</a> </a>
<SimpleDivider
v-if="showSeparator"
ref="divider"
:label="$t('COMMON.OR')"
class="uppercase"
/>
</div> </div>
</template> </template>

View File

@@ -5,6 +5,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals'; import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha'; import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import SimpleDivider from '../../../../../components/Divider/SimpleDivider.vue';
import FormInput from '../../../../../components/Form/Input.vue'; import FormInput from '../../../../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { isValidPassword } from 'shared/helpers/Validators'; import { isValidPassword } from 'shared/helpers/Validators';
@@ -17,6 +18,7 @@ export default {
FormInput, FormInput,
GoogleOAuthButton, GoogleOAuthButton,
NextButton, NextButton,
SimpleDivider,
VueHcaptcha, VueHcaptcha,
}, },
setup() { setup() {
@@ -210,9 +212,17 @@ export default {
:is-loading="isSignupInProgress" :is-loading="isSignupInProgress"
/> />
</form> </form>
<GoogleOAuthButton v-if="showGoogleOAuth" class="flex-col-reverse"> <div class="flex flex-col">
<SimpleDivider
v-if="showGoogleOAuth || showSamlLogin"
:label="$t('COMMON.OR')"
bg="bg-n-background"
class="uppercase"
/>
<GoogleOAuthButton v-if="showGoogleOAuth">
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }} {{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
</GoogleOAuthButton> </GoogleOAuthButton>
</div>
<p <p
class="text-sm mb-1 mt-5 text-n-slate-12 [&>a]:text-n-brand [&>a]:font-medium [&>a]:hover:brightness-110" class="text-sm mb-1 mt-5 text-n-slate-12 [&>a]:text-n-brand [&>a]:font-medium [&>a]:hover:brightness-110"
v-html="termsLink" v-html="termsLink"

View File

@@ -11,6 +11,7 @@ import SessionStorage from 'shared/helpers/sessionStorage';
import { useBranding } from 'shared/composables/useBranding'; import { useBranding } from 'shared/composables/useBranding';
// components // components
import SimpleDivider from '../../components/Divider/SimpleDivider.vue';
import FormInput from '../../components/Form/Input.vue'; import FormInput from '../../components/Form/Input.vue';
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue'; import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
@@ -30,6 +31,7 @@ export default {
GoogleOAuthButton, GoogleOAuthButton,
Spinner, Spinner,
NextButton, NextButton,
SimpleDivider,
MfaVerification, MfaVerification,
}, },
props: { props: {
@@ -253,7 +255,25 @@ export default {
}" }"
> >
<div v-if="!email"> <div v-if="!email">
<div class="flex flex-col">
<GoogleOAuthButton v-if="showGoogleOAuth" /> <GoogleOAuthButton v-if="showGoogleOAuth" />
<div v-if="showSamlLogin" class="mt-4 text-center">
<router-link
to="/app/login/sso"
class="inline-flex justify-center w-full px-4 py-3 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"
>
<span class="i-lucide-key h-6 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"> <form class="space-y-5" @submit.prevent="submitFormLogin">
<FormInput <FormInput
v-model="credentials.email" v-model="credentials.email"
@@ -305,13 +325,5 @@ export default {
<Spinner color-scheme="primary" size="" /> <Spinner color-scheme="primary" size="" />
</div> </div>
</section> </section>
<div v-if="showSamlLogin" class="mt-6 text-center">
<router-link
to="/app/login/sso"
class="inline-flex items-center text-sm font-medium text-n-brand hover:text-n-brand-dark"
>
{{ $t('LOGIN.SAML.LABEL') }}
</router-link>
</div>
</main> </main>
</template> </template>

View File

@@ -1,14 +1,22 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, nextTick, computed, onMounted } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { required, email } from '@vuelidate/validators'; import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
// components // components
import FormInput from '../../components/Form/Input.vue'; import FormInput from '../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
authError: {
type: String,
default: '',
},
});
const store = useStore(); const store = useStore();
const { t } = useI18n(); const { t } = useI18n();
@@ -21,6 +29,16 @@ const loginApi = ref({
hasErrored: 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 = { const validations = {
credentials: { credentials: {
email: { email: {
@@ -35,11 +53,13 @@ const v$ = useVuelidate(validations, { credentials });
const globalConfig = computed(() => store.getters['globalConfig/get']); const globalConfig = computed(() => store.getters['globalConfig/get']);
const csrfToken = ref(''); const csrfToken = ref('');
onMounted(() => { onMounted(async () => {
csrfToken.value = csrfToken.value =
document document
.querySelector('meta[name="csrf-token"]') .querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || ''; ?.getAttribute('content') || '';
await nextTick(handleAuthError);
}); });
</script> </script>
@@ -70,7 +90,6 @@ onMounted(() => {
}" }"
> >
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login"> <form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
<FormInput <FormInput
v-model="credentials.email" v-model="credentials.email"
name="email" name="email"
@@ -82,6 +101,12 @@ onMounted(() => {
:has-error="v$.credentials.email.$error" :has-error="v$.credentials.email.$error"
@input="v$.credentials.email.$touch" @input="v$.credentials.email.$touch"
/> />
<input
type="hidden"
class="h-0"
name="authenticity_token"
:value="csrfToken"
/>
<NextButton <NextButton
lg lg
type="submit" type="submit"

View File

@@ -26,6 +26,9 @@ export default [
name: 'sso_login', name: 'sso_login',
component: SamlLogin, component: SamlLogin,
meta: { requireEnterprise: true }, meta: { requireEnterprise: true },
props: route => ({
authError: route.query.error,
}),
}, },
{ {
path: frontendURL('auth/signup'), path: frontendURL('auth/signup'),

View File

@@ -5,6 +5,7 @@ json.availability_status resource.availability_status
json.auto_offline resource.auto_offline json.auto_offline resource.auto_offline
json.confirmed resource.confirmed? json.confirmed resource.confirmed?
json.email resource.email json.email resource.email
json.provider resource.provider
json.available_name resource.available_name json.available_name resource.available_name
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present? json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
json.name resource.name json.name resource.name

View File

@@ -44,6 +44,16 @@ class Api::V1::AuthController < Api::BaseController
end end
def render_saml_error def render_saml_error
render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized redirect_to sso_login_page_url(error: 'saml-authentication-failed')
end
def sso_login_page_url(error: nil)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
params = { error: error }.compact
query = params.to_query
query_fragment = query.present? ? "?#{query}" : ''
"#{frontend_url}/app/login/sso#{query_fragment}"
end end
end end

View File

@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
# Additional features available only in the Enterprise plan # Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding].freeze ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
def perform(event:) def perform(event:)
@event = event @event = event

View File

@@ -9,10 +9,8 @@ RSpec.describe 'Api::V1::Auth', type: :request do
before do before do
account.enable_features('saml') account.enable_features('saml')
account.save! account.save!
end allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('http://www.example.com')
def json_response
JSON.parse(response.body, symbolize_names: true)
end end
describe 'POST /api/v1/auth/saml_login' do describe 'POST /api/v1/auth/saml_login' do
@@ -33,10 +31,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
end end
context 'when user does not exist' do context 'when user does not exist' do
it 'returns unauthorized with generic message' do it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' } post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
expect(response).to have_http_status(:unauthorized) expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end end
end end
@@ -45,10 +43,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
create(:account_user, user: user, account: account) create(:account_user, user: user, account: account)
end end
it 'returns unauthorized' do it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: user.email } post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized) expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end end
end end
@@ -62,10 +60,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
account.save! account.save!
end end
it 'returns unauthorized' do it 'redirects to SSO login page with error' do
post '/api/v1/auth/saml_login', params: { email: user.email } post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:unauthorized) expect(response.location).to eq('http://www.example.com/app/login/sso?error=saml-authentication-failed')
end end
end end
@@ -82,21 +80,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
it 'redirects to SAML initiation URL' do it 'redirects to SAML initiation URL' do
post '/api/v1/auth/saml_login', params: { email: user.email } post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
it 'handles email case insensitivity' do
post '/api/v1/auth/saml_login', params: { email: user.email.upcase }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end
it 'strips whitespace from email' do
post '/api/v1/auth/saml_login', params: { email: " #{user.email} " }
expect(response).to have_http_status(:temporary_redirect)
expect(response.location).to include("/auth/saml?account_id=#{account.id}") expect(response.location).to include("/auth/saml?account_id=#{account.id}")
end end
end end
@@ -122,7 +105,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
it 'redirects to the first SAML enabled account' do it 'redirects to the first SAML enabled account' do
post '/api/v1/auth/saml_login', params: { email: user.email } post '/api/v1/auth/saml_login', params: { email: user.email }
expect(response).to have_http_status(:temporary_redirect)
returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i returned_account_id = response.location.match(/account_id=(\d+)/)[1].to_i
expect([account.id, account2.id]).to include(returned_account_id) expect([account.id, account2.id]).to include(returned_account_id)
end end