mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
feat: SAML feedback changes [CW-5666] (#12511)
This commit is contained in:
@@ -49,4 +49,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.CAPTAIN_V2,
|
||||
FEATURE_FLAGS.SAML,
|
||||
];
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"PLACEHOLDER": "Search",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
withBorder: { type: Boolean, default: false },
|
||||
hideContent: { type: Boolean, default: false },
|
||||
beta: { type: Boolean, default: false },
|
||||
});
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -17,8 +21,15 @@ defineProps({
|
||||
>
|
||||
<header class="grid grid-cols-4">
|
||||
<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>
|
||||
<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>
|
||||
<p class="text-n-slate-11 text-sm mt-2">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
customRoleId: {
|
||||
type: Number,
|
||||
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="w-[50%] ltr:text-left rtl:text-right">
|
||||
<Button
|
||||
v-if="provider !== 'saml'"
|
||||
ghost
|
||||
type="button"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
|
||||
@@ -261,6 +261,7 @@ const confirmDeletion = () => {
|
||||
v-if="showEditPopup"
|
||||
:id="currentAgent.id"
|
||||
:name="currentAgent.name"
|
||||
:provider="currentAgent.provider"
|
||||
:type="currentAgent.role"
|
||||
:email="currentAgent.email"
|
||||
:availability="currentAgent.availability_status"
|
||||
|
||||
@@ -171,6 +171,7 @@ onMounted(() => {
|
||||
<SectionLayout
|
||||
:title="t('SECURITY_SETTINGS.SAML.TITLE')"
|
||||
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
|
||||
beta
|
||||
:hide-content="!hasFeature || !isEnabled || isLoading"
|
||||
>
|
||||
<template #headerActions>
|
||||
|
||||
@@ -5,6 +5,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
bg: {
|
||||
type: String,
|
||||
default: 'bg-white dark:bg-n-solid-2',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -15,7 +19,7 @@ export default {
|
||||
<div class="w-full border-t border-n-strong" />
|
||||
</div>
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import GoogleOAuthButton from './Button.vue';
|
||||
|
||||
function getWrapper(showSeparator) {
|
||||
function getWrapper() {
|
||||
return shallowMount(GoogleOAuthButton, {
|
||||
propsData: { showSeparator: showSeparator },
|
||||
mocks: { $t: text => text },
|
||||
});
|
||||
}
|
||||
@@ -20,16 +19,6 @@ describe('GoogleOAuthButton.vue', () => {
|
||||
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', () => {
|
||||
const wrapper = getWrapper();
|
||||
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
|
||||
@@ -42,7 +31,5 @@ describe('GoogleOAuthButton.vue', () => {
|
||||
);
|
||||
expect(params.get('response_type')).toBe('code');
|
||||
expect(params.get('scope')).toBe('email profile');
|
||||
|
||||
expect(wrapper.findComponent({ ref: 'divider' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
<script>
|
||||
import SimpleDivider from '../Divider/SimpleDivider.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SimpleDivider,
|
||||
},
|
||||
props: {
|
||||
showSeparator: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getGoogleAuthUrl() {
|
||||
// Ideally a request to /auth/google_oauth2 should be made
|
||||
@@ -52,11 +41,5 @@ export default {
|
||||
{{ $t('LOGIN.OAUTH.GOOGLE_LOGIN') }}
|
||||
</span>
|
||||
</a>
|
||||
<SimpleDivider
|
||||
v-if="showSeparator"
|
||||
ref="divider"
|
||||
:label="$t('COMMON.OR')"
|
||||
class="uppercase"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import SimpleDivider from '../../../../../components/Divider/SimpleDivider.vue';
|
||||
import FormInput from '../../../../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { isValidPassword } from 'shared/helpers/Validators';
|
||||
@@ -17,6 +18,7 @@ export default {
|
||||
FormInput,
|
||||
GoogleOAuthButton,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
VueHcaptcha,
|
||||
},
|
||||
setup() {
|
||||
@@ -210,9 +212,17 @@ export default {
|
||||
:is-loading="isSignupInProgress"
|
||||
/>
|
||||
</form>
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth" class="flex-col-reverse">
|
||||
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
|
||||
</GoogleOAuthButton>
|
||||
<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') }}
|
||||
</GoogleOAuthButton>
|
||||
</div>
|
||||
<p
|
||||
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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import SessionStorage from 'shared/helpers/sessionStorage';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// 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';
|
||||
@@ -30,6 +31,7 @@ export default {
|
||||
GoogleOAuthButton,
|
||||
Spinner,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
MfaVerification,
|
||||
},
|
||||
props: {
|
||||
@@ -253,7 +255,25 @@ export default {
|
||||
}"
|
||||
>
|
||||
<div v-if="!email">
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth" />
|
||||
<div class="flex flex-col">
|
||||
<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">
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
@@ -305,13 +325,5 @@ export default {
|
||||
<Spinner color-scheme="primary" size="" />
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
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: '',
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -21,6 +29,16 @@ const loginApi = ref({
|
||||
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: {
|
||||
@@ -35,11 +53,13 @@ const v$ = useVuelidate(validations, { credentials });
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const csrfToken = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
csrfToken.value =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || '';
|
||||
|
||||
await nextTick(handleAuthError);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -70,7 +90,6 @@ onMounted(() => {
|
||||
}"
|
||||
>
|
||||
<form class="space-y-5" method="POST" action="/api/v1/auth/saml_login">
|
||||
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
name="email"
|
||||
@@ -82,6 +101,12 @@ onMounted(() => {
|
||||
:has-error="v$.credentials.email.$error"
|
||||
@input="v$.credentials.email.$touch"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
class="h-0"
|
||||
name="authenticity_token"
|
||||
:value="csrfToken"
|
||||
/>
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
|
||||
@@ -26,6 +26,9 @@ export default [
|
||||
name: 'sso_login',
|
||||
component: SamlLogin,
|
||||
meta: { requireEnterprise: true },
|
||||
props: route => ({
|
||||
authError: route.query.error,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/signup'),
|
||||
|
||||
@@ -5,6 +5,7 @@ json.availability_status resource.availability_status
|
||||
json.auto_offline resource.auto_offline
|
||||
json.confirmed resource.confirmed?
|
||||
json.email resource.email
|
||||
json.provider resource.provider
|
||||
json.available_name resource.available_name
|
||||
json.custom_attributes resource.custom_attributes if resource.custom_attributes.present?
|
||||
json.name resource.name
|
||||
|
||||
@@ -44,6 +44,16 @@ class Api::V1::AuthController < Api::BaseController
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -22,7 +22,7 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||
|
||||
# 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:)
|
||||
@event = event
|
||||
|
||||
@@ -9,10 +9,8 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
before do
|
||||
account.enable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with('FRONTEND_URL', nil).and_return('http://www.example.com')
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/auth/saml_login' do
|
||||
@@ -33,10 +31,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
end
|
||||
|
||||
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' }
|
||||
|
||||
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
|
||||
|
||||
@@ -45,10 +43,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
create(:account_user, user: user, account: account)
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
it 'redirects to SSO login page with error' do
|
||||
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
|
||||
|
||||
@@ -62,10 +60,10 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
it 'redirects to SSO login page with error' do
|
||||
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
|
||||
|
||||
@@ -82,21 +80,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
it 'redirects to SAML initiation URL' 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}")
|
||||
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}")
|
||||
end
|
||||
end
|
||||
@@ -122,7 +105,6 @@ RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
it 'redirects to the first SAML enabled account' do
|
||||
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
|
||||
expect([account.id, account2.id]).to include(returned_account_id)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user