Merge branch 'develop' into feat/CW-5624
This commit is contained in:
@@ -12,9 +12,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
return handle_mfa_verification if mfa_verification_request?
|
||||
return handle_sso_authentication if sso_authentication_request?
|
||||
|
||||
super do |resource|
|
||||
return handle_mfa_required(resource) if resource&.mfa_enabled?
|
||||
end
|
||||
user = find_user_for_authentication
|
||||
return handle_mfa_required(user) if user&.mfa_enabled?
|
||||
|
||||
# Only proceed with standard authentication if no MFA is required
|
||||
super
|
||||
end
|
||||
|
||||
def render_create_success
|
||||
@@ -23,6 +25,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
|
||||
private
|
||||
|
||||
def find_user_for_authentication
|
||||
return nil unless params[:email].present? && params[:password].present?
|
||||
|
||||
normalized_email = params[:email].strip.downcase
|
||||
user = User.from_email(normalized_email)
|
||||
return nil unless user&.valid_password?(params[:password])
|
||||
return nil unless user.active_for_authentication?
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def mfa_verification_request?
|
||||
params[:mfa_token].present?
|
||||
end
|
||||
@@ -59,10 +72,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
|
||||
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
|
||||
end
|
||||
|
||||
def handle_mfa_required(resource)
|
||||
def handle_mfa_required(user)
|
||||
render json: {
|
||||
mfa_required: true,
|
||||
mfa_token: Mfa::TokenService.new(user: resource).generate_token
|
||||
mfa_token: Mfa::TokenService.new(user: user).generate_token
|
||||
}, status: :partial_content
|
||||
end
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
|
||||
redirect_to new_super_admin_user_path, notice: notice
|
||||
end
|
||||
end
|
||||
#
|
||||
# def update
|
||||
# super
|
||||
# send_foo_updated_email(requested_resource)
|
||||
# end
|
||||
|
||||
def update
|
||||
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
|
||||
super
|
||||
end
|
||||
|
||||
# Override this method to specify custom lookup behavior.
|
||||
# This will be used to set the resource for the `show`, `edit`, and `update`
|
||||
|
||||
@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
SHOW_PAGE_ATTRIBUTES = %i[
|
||||
id
|
||||
avatar_url
|
||||
unconfirmed_email
|
||||
name
|
||||
type
|
||||
display_name
|
||||
email
|
||||
unconfirmed_email
|
||||
created_at
|
||||
updated_at
|
||||
confirmed_at
|
||||
|
||||
@@ -555,10 +555,12 @@
|
||||
"WROTE": "wrote",
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
"ADD_NOTE": "Add contact note",
|
||||
"EXPAND": "Expand",
|
||||
"COLLAPSE": "Collapse",
|
||||
"NO_NOTES": "No notes, you can add notes from the contact details page.",
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
|
||||
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
|
||||
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
|
||||
}
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
|
||||
@@ -22,6 +22,20 @@
|
||||
},
|
||||
"FORGOT_PASSWORD": "Forgot your password?",
|
||||
"CREATE_NEW_ACCOUNT": "Create a new account",
|
||||
"SUBMIT": "Login"
|
||||
"SUBMIT": "Login",
|
||||
"SAML": {
|
||||
"LABEL": "Log in via SSO",
|
||||
"TITLE": "Initiate Single Sign-on (SSO)",
|
||||
"SUBTITLE": "Enter your work email to access your organization",
|
||||
"BACK_TO_LOGIN": "Login via Password",
|
||||
"WORK_EMAIL": {
|
||||
"LABEL": "Work Email",
|
||||
"PLACEHOLDER": "Enter your work email"
|
||||
},
|
||||
"SUBMIT": "Continue with SSO",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "SSO authentication failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
<script setup>
|
||||
import { watch, computed } from 'vue';
|
||||
import { watch, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'next/spinner/Spinner.vue';
|
||||
|
||||
const { contactId } = defineProps({
|
||||
contactId: { type: String, required: true },
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
|
||||
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const notes = computed(() => notGetterFn.value(contactId));
|
||||
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
||||
const contactId = computed(() => props.contactId);
|
||||
const noteContent = ref('');
|
||||
const shouldShowCreateModal = ref(false);
|
||||
const notes = computed(() => {
|
||||
if (!contactId.value) {
|
||||
return [];
|
||||
}
|
||||
return notesByContact.value(contactId.value) || [];
|
||||
});
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
@@ -24,28 +37,130 @@ const getWrittenBy = ({ user } = {}) => {
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
if (!contactId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
noteContent.value = '';
|
||||
shouldShowCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
shouldShowCreateModal.value = false;
|
||||
noteContent.value = '';
|
||||
};
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!contactId.value || !noteContent.value || isCreatingNote.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await store.dispatch('contactNotes/create', {
|
||||
content: noteContent.value,
|
||||
contactId: contactId.value,
|
||||
});
|
||||
noteContent.value = '';
|
||||
closeCreateModal();
|
||||
};
|
||||
|
||||
const onDelete = noteId => {
|
||||
if (!contactId.value || !noteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
store.dispatch('contactNotes/delete', {
|
||||
noteId,
|
||||
contactId: contactId.value,
|
||||
});
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Enter': {
|
||||
action: onAdd,
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
watch(
|
||||
() => contactId,
|
||||
() => store.dispatch('contactNotes/get', { contactId }),
|
||||
contactId,
|
||||
newContactId => {
|
||||
closeCreateModal();
|
||||
if (newContactId) {
|
||||
store.dispatch('contactNotes/get', { contactId: newContactId });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
|
||||
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
|
||||
</div>
|
||||
<div v-else class="max-h-[300px] overflow-scroll">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="p-4 last-of-type:border-b-0"
|
||||
:note="note"
|
||||
collapsible
|
||||
:written-by="getWrittenBy(note)"
|
||||
/>
|
||||
<div>
|
||||
<div class="px-4 pt-3 pb-2">
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
|
||||
:disabled="!contactId || isFetchingNotes"
|
||||
@click="openCreateModal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetchingNotes"
|
||||
class="flex items-center justify-center py-8 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="notes.length"
|
||||
class="flex flex-col max-h-[300px] overflow-y-auto"
|
||||
>
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="py-4 last-of-type:border-b-0 px-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
collapsible
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
|
||||
</p>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="shouldShowCreateModal"
|
||||
:on-close="closeCreateModal"
|
||||
:close-on-backdrop-click="false"
|
||||
class="!items-start [&>div]:!top-12 [&>div]:sticky"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6 px-6 py-6">
|
||||
<h3 class="text-lg font-semibold text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE') }}
|
||||
</h3>
|
||||
<Editor
|
||||
v-model="noteContent"
|
||||
focus-on-mount
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||
:is-loading="isCreatingNote"
|
||||
:disabled="!noteContent || isCreatingNote"
|
||||
@click="onAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,9 +29,9 @@ const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000);
|
||||
const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000);
|
||||
const superLongDebouncedFetchMetaData = debounce(
|
||||
fetchMetaData,
|
||||
2000,
|
||||
1500,
|
||||
false,
|
||||
5000
|
||||
10000
|
||||
);
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseBoolean } from '@chatwoot/utils';
|
||||
|
||||
const {
|
||||
API_CHANNEL_NAME: apiChannelName,
|
||||
API_CHANNEL_THUMBNAIL: apiChannelThumbnail,
|
||||
@@ -15,6 +17,7 @@ const {
|
||||
LOGO: logo,
|
||||
LOGO_DARK: logoDark,
|
||||
PRIVACY_URL: privacyURL,
|
||||
IS_ENTERPRISE: isEnterprise,
|
||||
TERMS_URL: termsURL,
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
@@ -30,8 +33,8 @@ const state = {
|
||||
chatwootInboxToken,
|
||||
deploymentEnv,
|
||||
createNewAccountFromDashboard,
|
||||
directUploadsEnabled: directUploadsEnabled === 'true',
|
||||
disableUserProfileUpdate: disableUserProfileUpdate === 'true',
|
||||
directUploadsEnabled: parseBoolean(directUploadsEnabled),
|
||||
disableUserProfileUpdate: parseBoolean(disableUserProfileUpdate),
|
||||
displayManifest,
|
||||
gitSha,
|
||||
hCaptchaSiteKey,
|
||||
@@ -42,6 +45,7 @@ const state = {
|
||||
privacyURL,
|
||||
termsURL,
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
@@ -55,6 +55,7 @@ const model = defineModel({
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
v-model="model"
|
||||
:name="name"
|
||||
:type="type"
|
||||
class="block w-full border-none rounded-md shadow-sm bg-n-alpha-black2 appearance-none outline outline-1 focus:outline focus:outline-1 text-n-slate-12 placeholder:text-n-slate-10 sm:text-sm sm:leading-6 px-3 py-3"
|
||||
:class="{
|
||||
|
||||
@@ -41,7 +41,14 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
|
||||
to.meta &&
|
||||
to.meta.requireSignupEnabled;
|
||||
|
||||
if (!to.name || isAnInalidSignupNavigation) {
|
||||
// Disable navigation to SAML login if enterprise is not enabled
|
||||
// SAML route has an attribute (requireEnterprise) in it's definition
|
||||
const isEnterpriseOnlyPath =
|
||||
chatwootConfig.isEnterprise !== 'true' &&
|
||||
to.meta &&
|
||||
to.meta.requireEnterprise;
|
||||
|
||||
if (!to.name || isAnInalidSignupNavigation || isEnterpriseOnlyPath) {
|
||||
next(frontendURL('login'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ export default {
|
||||
showSignupLink() {
|
||||
return parseBoolean(window.chatwootConfig.signupEnabled);
|
||||
},
|
||||
showSamlLogin() {
|
||||
return this.globalConfig.isEnterprise;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (this.ssoAuthToken) {
|
||||
@@ -302,5 +305,13 @@ 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>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// components
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const credentials = ref({
|
||||
email: '',
|
||||
});
|
||||
|
||||
const loginApi = ref({
|
||||
showLoading: false,
|
||||
hasErrored: false,
|
||||
});
|
||||
|
||||
const validations = {
|
||||
credentials: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validations, { credentials });
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const csrfToken = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
csrfToken.value =
|
||||
document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute('content') || '';
|
||||
});
|
||||
</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">
|
||||
<input type="hidden" name="authenticity_token" :value="csrfToken" I />
|
||||
<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"
|
||||
/>
|
||||
<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,6 +1,7 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Login from './login/Index.vue';
|
||||
import SamlLogin from './login/Saml.vue';
|
||||
import Signup from './auth/signup/Index.vue';
|
||||
import ResetPassword from './auth/reset/password/Index.vue';
|
||||
import Confirmation from './auth/confirmation/Index.vue';
|
||||
@@ -20,6 +21,12 @@ export default [
|
||||
authError: route.query.error,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('login/sso'),
|
||||
name: 'sso_login',
|
||||
component: SamlLogin,
|
||||
meta: { requireEnterprise: true },
|
||||
},
|
||||
{
|
||||
path: frontendURL('auth/signup'),
|
||||
name: 'auth_signup',
|
||||
|
||||
@@ -36,6 +36,10 @@ en:
|
||||
success: 'Channel reauthorized successfully'
|
||||
not_required: 'Reauthorization is not required for this inbox'
|
||||
invalid_channel: 'Invalid channel type for reauthorization'
|
||||
auth:
|
||||
saml:
|
||||
invalid_email: 'Please enter a valid email address'
|
||||
authentication_failed: 'Authentication failed. Please check your credentials and try again.'
|
||||
messages:
|
||||
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
|
||||
reset_password_failure: Uh ho! We could not find any user with the specified email.
|
||||
|
||||
@@ -325,6 +325,9 @@ Rails.application.routes.draw do
|
||||
resources :webhooks, only: [:create]
|
||||
end
|
||||
|
||||
# Frontend API endpoint to trigger SAML authentication flow
|
||||
post 'auth/saml_login', to: 'auth#saml_login'
|
||||
|
||||
resource :profile, only: [:show, :update] do
|
||||
delete :avatar, on: :collection
|
||||
member do
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class Api::V1::AuthController < Api::BaseController
|
||||
skip_before_action :authenticate_user!, only: [:saml_login]
|
||||
before_action :find_user_and_account, only: [:saml_login]
|
||||
|
||||
def saml_login
|
||||
return if @account.nil?
|
||||
|
||||
saml_initiation_url = "/auth/saml?account_id=#{@account.id}"
|
||||
redirect_to saml_initiation_url, status: :temporary_redirect
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_user_and_account
|
||||
return unless validate_email_presence
|
||||
|
||||
find_saml_enabled_account
|
||||
end
|
||||
|
||||
def validate_email_presence
|
||||
@email = params[:email]&.downcase&.strip
|
||||
return true if @email.present?
|
||||
|
||||
render json: { error: I18n.t('auth.saml.invalid_email') }, status: :bad_request
|
||||
false
|
||||
end
|
||||
|
||||
def find_saml_enabled_account
|
||||
user = User.from_email(@email)
|
||||
return render_saml_error unless user
|
||||
|
||||
account_user = find_account_with_saml(user)
|
||||
return render_saml_error unless account_user
|
||||
|
||||
@account = account_user.account
|
||||
end
|
||||
|
||||
def find_account_with_saml(user)
|
||||
user.account_users
|
||||
.joins(account: :saml_settings)
|
||||
.where.not(saml_settings: { sso_url: [nil, ''] })
|
||||
.where.not(saml_settings: { certificate: [nil, ''] })
|
||||
.find { |account_user| account_user.account.feature_enabled?('saml') }
|
||||
end
|
||||
|
||||
def render_saml_error
|
||||
render json: { error: I18n.t('auth.saml.authentication_failed') }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,8 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
Captain::Llm::PaginatedFaqGeneratorService.new(
|
||||
document,
|
||||
pages_per_chunk: options[:pages_per_chunk],
|
||||
max_pages: options[:max_pages]
|
||||
max_pages: options[:max_pages],
|
||||
language: document.account.locale_english_name
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
|
||||
def initialize(document, options = {})
|
||||
super()
|
||||
@document = document
|
||||
@language = options[:language] || 'english'
|
||||
@pages_per_chunk = options[:pages_per_chunk] || DEFAULT_PAGES_PER_CHUNK
|
||||
@max_pages = options[:max_pages] # Optional limit from UI
|
||||
@total_pages_processed = 0
|
||||
@@ -118,7 +119,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
|
||||
end
|
||||
|
||||
def page_chunk_prompt(start_page, end_page)
|
||||
Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page)
|
||||
Captain::Llm::SystemPromptsService.paginated_faq_generator(start_page, end_page, @language)
|
||||
end
|
||||
|
||||
def standard_chat_parameters
|
||||
@@ -128,7 +129,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.faq_generator
|
||||
content: Captain::Llm::SystemPromptsService.faq_generator(@language)
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -206,7 +206,7 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def paginated_faq_generator(start_page, end_page)
|
||||
def paginated_faq_generator(start_page, end_page, language = 'english')
|
||||
<<~PROMPT
|
||||
You are an expert technical documentation specialist tasked with creating comprehensive FAQs from a SPECIFIC SECTION of a document.
|
||||
|
||||
@@ -226,6 +226,8 @@ class Captain::Llm::SystemPromptsService
|
||||
FAQ GENERATION GUIDELINES
|
||||
════════════════════════════════════════════════════════
|
||||
|
||||
**Language**: Generate the FAQs only in #{language}, use no other language
|
||||
|
||||
1. **Comprehensive Extraction**
|
||||
• Extract ALL information that could generate FAQs from this section
|
||||
• Target 5-10 FAQs per page equivalent of rich content
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
module MfaTasks
|
||||
def self.find_user_or_exit(email)
|
||||
abort 'Error: Please provide an email address' if email.blank?
|
||||
user = User.from_email(email)
|
||||
abort "Error: User with email '#{email}' not found" unless user
|
||||
user
|
||||
end
|
||||
|
||||
def self.reset_user_mfa(user)
|
||||
user.update!(
|
||||
otp_required_for_login: false,
|
||||
otp_secret: nil,
|
||||
otp_backup_codes: nil
|
||||
)
|
||||
end
|
||||
|
||||
def self.reset_single(args)
|
||||
user = find_user_or_exit(args[:email])
|
||||
abort "MFA is already disabled for #{args[:email]}" if !user.otp_required_for_login? && user.otp_secret.nil?
|
||||
reset_user_mfa(user)
|
||||
puts "✓ MFA has been successfully reset for #{args[:email]}"
|
||||
rescue StandardError => e
|
||||
abort "Error resetting MFA: #{e.message}"
|
||||
end
|
||||
|
||||
def self.reset_all
|
||||
print 'Are you sure you want to reset MFA for ALL users? This cannot be undone! (yes/no): '
|
||||
abort 'Operation cancelled' unless $stdin.gets.chomp.downcase == 'yes'
|
||||
|
||||
affected_users = User.where(otp_required_for_login: true).or(User.where.not(otp_secret: nil))
|
||||
count = affected_users.count
|
||||
abort 'No users have MFA enabled' if count.zero?
|
||||
|
||||
puts "\nResetting MFA for #{count} user(s)..."
|
||||
affected_users.find_each { |user| reset_user_mfa(user) }
|
||||
puts "✓ MFA has been reset for #{count} user(s)"
|
||||
end
|
||||
|
||||
def self.generate_backup_codes(args)
|
||||
user = find_user_or_exit(args[:email])
|
||||
abort "Error: MFA is not enabled for #{args[:email]}" unless user.otp_required_for_login?
|
||||
|
||||
service = Mfa::ManagementService.new(user: user)
|
||||
codes = service.generate_backup_codes!
|
||||
puts "\nNew backup codes generated for #{args[:email]}:"
|
||||
codes.each { |code| puts code }
|
||||
end
|
||||
end
|
||||
|
||||
namespace :mfa do
|
||||
desc 'Reset MFA for a specific user by email'
|
||||
task :reset, [:email] => :environment do |_task, args|
|
||||
MfaTasks.reset_single(args)
|
||||
end
|
||||
|
||||
desc 'Reset MFA for all users in the system'
|
||||
task reset_all: :environment do
|
||||
MfaTasks.reset_all
|
||||
end
|
||||
|
||||
desc 'Generate new backup codes for a user'
|
||||
task :generate_backup_codes, [:email] => :environment do |_task, args|
|
||||
MfaTasks.generate_backup_codes(args)
|
||||
end
|
||||
end
|
||||
@@ -40,6 +40,26 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do
|
||||
expect(json_response['mfa_token']).to be_present
|
||||
end
|
||||
|
||||
it 'does not return authentication tokens before MFA verification' do
|
||||
post :create, params: { email: user.email, password: 'Test@123456' }
|
||||
|
||||
expect(response).to have_http_status(:partial_content)
|
||||
|
||||
# Check that no authentication headers are present
|
||||
expect(response.headers['access-token']).to be_nil
|
||||
expect(response.headers['uid']).to be_nil
|
||||
expect(response.headers['client']).to be_nil
|
||||
expect(response.headers['Authorization']).to be_nil
|
||||
|
||||
# Check that no bearer token is present in any form
|
||||
response.headers.each do |key, value|
|
||||
expect(value.to_s).not_to include('Bearer') if key.downcase.include?('auth')
|
||||
end
|
||||
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['data']).to be_nil
|
||||
end
|
||||
|
||||
context 'when verifying MFA' do
|
||||
let(:mfa_token) { Mfa::TokenService.new(user: user).generate_token }
|
||||
|
||||
|
||||
@@ -66,4 +66,38 @@ RSpec.describe 'Super Admin Users API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /super_admin/users/:id' do
|
||||
let!(:user) { create(:user) }
|
||||
let(:request_path) { "/super_admin/users/#{user.id}" }
|
||||
|
||||
before { sign_in(super_admin, scope: :super_admin) }
|
||||
|
||||
it 'skips reconfirmation when confirmed_at is provided' do
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
patch request_path, params: { user: { email: 'updated@example.com', confirmed_at: Time.current } }
|
||||
|
||||
expect(response).to have_http_status(:redirect)
|
||||
expect(user.reload.email).to eq('updated@example.com')
|
||||
expect(user.reload.unconfirmed_email).to be_nil
|
||||
|
||||
mail_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
||||
job[:job].to_s == 'ActionMailer::MailDeliveryJob'
|
||||
end
|
||||
expect(mail_jobs.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'does not skip reconfirmation when confirmed_at is blank' do
|
||||
ActiveJob::Base.queue_adapter.enqueued_jobs.clear
|
||||
patch request_path, params: { user: { email: 'updated-again@example.com' } }
|
||||
|
||||
expect(response).to have_http_status(:redirect)
|
||||
expect(user.reload.unconfirmed_email).to eq('updated-again@example.com')
|
||||
|
||||
mail_jobs = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
||||
job[:job].to_s == 'ActionMailer::MailDeliveryJob'
|
||||
end
|
||||
expect(mail_jobs.count).to be >= 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Auth', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, email: 'user@example.com') }
|
||||
|
||||
before do
|
||||
account.enable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/auth/saml_login' do
|
||||
context 'when email is blank' do
|
||||
it 'returns bad request' do
|
||||
post '/api/v1/auth/saml_login', params: { email: '' }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email is nil' do
|
||||
it 'returns bad request' do
|
||||
post '/api/v1/auth/saml_login', params: {}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'returns unauthorized with generic message' do
|
||||
post '/api/v1/auth/saml_login', params: { email: 'nonexistent@example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user exists but has no SAML enabled accounts' do
|
||||
before do
|
||||
create(:account_user, user: user, account: account)
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has account without SAML feature enabled' do
|
||||
let(:saml_settings) { create(:account_saml_settings, account: account) }
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
create(:account_user, user: user, account: account)
|
||||
account.disable_features('saml')
|
||||
account.save!
|
||||
end
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post '/api/v1/auth/saml_login', params: { email: user.email }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has valid SAML configuration' do
|
||||
let(:saml_settings) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
|
||||
before do
|
||||
saml_settings
|
||||
create(:account_user, user: user, account: account)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
context 'when user has multiple accounts with SAML' do
|
||||
let(:account2) { create(:account) }
|
||||
let(:saml_settings1) do
|
||||
create(:account_saml_settings, account: account)
|
||||
end
|
||||
let(:saml_settings2) do
|
||||
create(:account_saml_settings, account: account2)
|
||||
end
|
||||
|
||||
before do
|
||||
account2.enable_features('saml')
|
||||
account2.save!
|
||||
saml_settings1
|
||||
saml_settings2
|
||||
create(:account_user, user: user, account: account)
|
||||
create(:account_user, user: user, account: account2)
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user