feat: ensure signup verification [UPM-14] (#13858)
Previously, signing up gave immediate access to the app. Now, unconfirmed users are redirected to a verification page where they can resend the confirmation email. - After signup, the user is routed to `/auth/verify-email` instead of the dashboard - After login, unconfirmed users are redirected to the verification page - The dashboard route guard catches unconfirmed users and redirects them - `active_for_authentication?` is removed from the sessions controller so unconfirmed users can authenticate — the frontend gates access instead - If the user visits the verification page after already confirming, they're automatically redirected to the dashboard - No session is issued until the user is verified <details><summary>Demo</summary> <p> #### Fresh Signup https://github.com/user-attachments/assets/abb735e5-7c8e-44a2-801c-96d9e4823e51 #### Google Fresh Signup https://github.com/user-attachments/assets/ab9e389a-a604-4a9d-b492-219e6d94ee3f #### Create new account from Dashboard https://github.com/user-attachments/assets/c456690d-1946-4e0b-834b-ad8efcea8369 </p> </details> --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -31,8 +31,18 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
user: current_user
|
user: current_user
|
||||||
).perform
|
).perform
|
||||||
if @user
|
if @user
|
||||||
send_auth_headers(@user)
|
# Authenticated users (dashboard "add account") and api_only signups
|
||||||
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
# need the full response with account_id. API-only deployments have no
|
||||||
|
# frontend to handle the email confirmation flow, so they need auth
|
||||||
|
# tokens to proceed.
|
||||||
|
# Unauthenticated web signup returns only the email — no session is
|
||||||
|
# created until the user confirms via the email link.
|
||||||
|
if current_user || api_only_signup?
|
||||||
|
send_auth_headers(@user)
|
||||||
|
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
|
||||||
|
else
|
||||||
|
render json: { email: @user.email }
|
||||||
|
end
|
||||||
else
|
else
|
||||||
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
|
||||||
end
|
end
|
||||||
@@ -103,6 +113,15 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def api_only_signup?
|
||||||
|
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
|
||||||
|
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
|
||||||
|
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
|
||||||
|
# typecasts it to boolean, coercing 'api_only' to true.
|
||||||
|
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
|
||||||
|
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
|
||||||
|
end
|
||||||
|
|
||||||
def validate_captcha
|
def validate_captcha
|
||||||
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||||
end
|
end
|
||||||
|
|||||||
18
app/controllers/auth/resend_confirmations_controller.rb
Normal file
18
app/controllers/auth/resend_confirmations_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Unauthenticated endpoint for resending confirmation emails during signup.
|
||||||
|
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
|
||||||
|
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
|
||||||
|
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
|
||||||
|
# Inherits from ActionController::API to avoid both issues entirely.
|
||||||
|
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
|
||||||
|
class Auth::ResendConfirmationsController < ActionController::API
|
||||||
|
def create
|
||||||
|
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
|
||||||
|
|
||||||
|
email = params[:email]
|
||||||
|
return head(:ok) unless email.is_a?(String)
|
||||||
|
|
||||||
|
user = User.from_email(email.strip.downcase)
|
||||||
|
user&.send_confirmation_instructions unless user&.confirmed?
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -45,6 +45,13 @@
|
|||||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||||
},
|
},
|
||||||
"SUBMIT": "Create account",
|
"SUBMIT": "Create account",
|
||||||
"HAVE_AN_ACCOUNT": "Already have an account?"
|
"HAVE_AN_ACCOUNT": "Already have an account?",
|
||||||
|
"VERIFY_EMAIL": {
|
||||||
|
"TITLE": "Check your inbox",
|
||||||
|
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
|
||||||
|
"RESEND": "Resend verification email",
|
||||||
|
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
|
||||||
|
"RESEND_ERROR": "Could not send verification email. Please try again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ export const register = async creds => {
|
|||||||
password: creds.password,
|
password: creds.password,
|
||||||
h_captcha_client_response: creds.hCaptchaClientResponse,
|
h_captcha_client_response: creds.hCaptchaClientResponse,
|
||||||
});
|
});
|
||||||
setAuthCredentials(response);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwErrorMessage(error);
|
throwErrorMessage(error);
|
||||||
@@ -65,6 +64,13 @@ export const register = async creds => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
|
||||||
|
return wootAPI.post('resend_confirmation', {
|
||||||
|
email,
|
||||||
|
h_captcha_client_response: hCaptchaClientResponse,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const verifyPasswordToken = async ({ confirmationToken }) => {
|
export const verifyPasswordToken = async ({ confirmationToken }) => {
|
||||||
try {
|
try {
|
||||||
const response = await wootAPI.post('auth/confirmation', {
|
const response = await wootAPI.post('auth/confirmation', {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useVuelidate } from '@vuelidate/core';
|
|||||||
import { required, minLength, email } from '@vuelidate/validators';
|
import { required, minLength, email } from '@vuelidate/validators';
|
||||||
import { useStore } from 'vuex';
|
import { useStore } from 'vuex';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
|
||||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||||
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';
|
||||||
@@ -19,6 +19,7 @@ const MIN_PASSWORD_LENGTH = 6;
|
|||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const hCaptcha = ref(null);
|
const hCaptcha = ref(null);
|
||||||
const isPasswordFocused = ref(false);
|
const isPasswordFocused = ref(false);
|
||||||
@@ -76,7 +77,10 @@ const performRegistration = async () => {
|
|||||||
isSignupInProgress.value = true;
|
isSignupInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
await register(credentials);
|
await register(credentials);
|
||||||
window.location = DEFAULT_REDIRECT_URL;
|
router.push({
|
||||||
|
name: 'auth_verify_email',
|
||||||
|
state: { email: credentials.email },
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
|
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
|
||||||
if (globalConfig.value.hCaptchaSiteKey) {
|
if (globalConfig.value.hCaptchaSiteKey) {
|
||||||
|
|||||||
110
app/javascript/v3/views/auth/verify-email/Index.vue
Normal file
110
app/javascript/v3/views/auth/verify-email/Index.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore } from 'vuex';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { resendConfirmation } from '../../../api/auth';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
if (!props.email) {
|
||||||
|
router.push({ name: 'login' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||||
|
const isResendingEmail = ref(false);
|
||||||
|
const hCaptcha = ref(null);
|
||||||
|
let captchaToken = '';
|
||||||
|
|
||||||
|
const performResend = async () => {
|
||||||
|
isResendingEmail.value = true;
|
||||||
|
try {
|
||||||
|
await resendConfirmation({
|
||||||
|
email: props.email,
|
||||||
|
hCaptchaClientResponse: captchaToken,
|
||||||
|
});
|
||||||
|
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_SUCCESS'));
|
||||||
|
} catch {
|
||||||
|
useAlert(t('REGISTER.VERIFY_EMAIL.RESEND_ERROR'));
|
||||||
|
} finally {
|
||||||
|
isResendingEmail.value = false;
|
||||||
|
captchaToken = '';
|
||||||
|
if (globalConfig.value.hCaptchaSiteKey) {
|
||||||
|
hCaptcha.value.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendEmail = () => {
|
||||||
|
if (isResendingEmail.value) return;
|
||||||
|
if (globalConfig.value.hCaptchaSiteKey) {
|
||||||
|
hCaptcha.value.execute();
|
||||||
|
} else {
|
||||||
|
performResend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCaptchaVerified = token => {
|
||||||
|
captchaToken = token;
|
||||||
|
performResend();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCaptchaError = () => {
|
||||||
|
isResendingEmail.value = false;
|
||||||
|
captchaToken = '';
|
||||||
|
hCaptcha.value.reset();
|
||||||
|
};
|
||||||
|
</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="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"
|
||||||
|
>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||||
|
{{ $t('REGISTER.VERIFY_EMAIL.TITLE') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-n-slate-11">
|
||||||
|
{{ $t('REGISTER.VERIFY_EMAIL.DESCRIPTION', { email }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<VueHcaptcha
|
||||||
|
v-if="globalConfig.hCaptchaSiteKey"
|
||||||
|
ref="hCaptcha"
|
||||||
|
size="invisible"
|
||||||
|
:sitekey="globalConfig.hCaptchaSiteKey"
|
||||||
|
@verify="onCaptchaVerified"
|
||||||
|
@error="onCaptchaError"
|
||||||
|
@expired="onCaptchaError"
|
||||||
|
@challenge-expired="onCaptchaError"
|
||||||
|
@closed="onCaptchaError"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
lg
|
||||||
|
type="button"
|
||||||
|
data-testid="resend_email_button"
|
||||||
|
class="w-full"
|
||||||
|
:label="$t('REGISTER.VERIFY_EMAIL.RESEND')"
|
||||||
|
:is-loading="isResendingEmail"
|
||||||
|
@click="handleResendEmail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,7 @@ import SamlLogin from './login/Saml.vue';
|
|||||||
import Signup from './auth/signup/Index.vue';
|
import Signup from './auth/signup/Index.vue';
|
||||||
import ResetPassword from './auth/reset/password/Index.vue';
|
import ResetPassword from './auth/reset/password/Index.vue';
|
||||||
import Confirmation from './auth/confirmation/Index.vue';
|
import Confirmation from './auth/confirmation/Index.vue';
|
||||||
|
import VerifyEmail from './auth/verify-email/Index.vue';
|
||||||
import PasswordEdit from './auth/password/Edit.vue';
|
import PasswordEdit from './auth/password/Edit.vue';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
@@ -48,6 +49,15 @@ export default [
|
|||||||
redirectUrl: route.query.route_url,
|
redirectUrl: route.query.route_url,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: frontendURL('auth/verify-email'),
|
||||||
|
name: 'auth_verify_email',
|
||||||
|
component: VerifyEmail,
|
||||||
|
meta: { ignoreSession: true },
|
||||||
|
props: () => ({
|
||||||
|
email: window.history.state?.email || '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('auth/password/edit'),
|
path: frontendURL('auth/password/edit'),
|
||||||
name: 'auth_password_edit',
|
name: 'auth_password_edit',
|
||||||
|
|||||||
@@ -120,8 +120,20 @@ class Rack::Attack
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
## Resend confirmation throttling
|
## Resend confirmation throttling (unauthenticated)
|
||||||
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
|
||||||
|
req.ip if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
|
||||||
|
if req.path_without_extentions == '/resend_confirmation' && req.post?
|
||||||
|
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
|
||||||
|
email.to_s.downcase.gsub(/\s+/, '')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
## Resend confirmation throttling (authenticated)
|
||||||
|
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
|
||||||
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
req.ip if req.path_without_extentions == '/api/v1/profile/resend_confirmation' && req.post?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ Rails.application.routes.draw do
|
|||||||
omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
|
omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
|
||||||
}, via: [:get, :post]
|
}, via: [:get, :post]
|
||||||
|
|
||||||
|
post 'resend_confirmation', to: 'auth/resend_confirmations#create'
|
||||||
|
|
||||||
## renders the frontend paths only if its not an api only server
|
## renders the frontend paths only if its not an api only server
|
||||||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
|
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
|
||||||
root to: 'api#index'
|
root to: 'api#index'
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
|
|
||||||
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
expect(AccountBuilder).to have_received(:new).with(params.except(:password).merge(user_password: params[:password]))
|
||||||
expect(account_builder).to have_received(:perform)
|
expect(account_builder).to have_received(:perform)
|
||||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||||
expect(response.body).to include('en')
|
expect(response.parsed_body['email']).to eq(email)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(ChatwootCaptcha).to have_received(:new).with('123')
|
expect(ChatwootCaptcha).to have_received(:new).with('123')
|
||||||
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||||
expect(response.body).to include('en')
|
expect(response.parsed_body['email']).to eq(email)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when an authenticated user creates a second account' do
|
||||||
|
let(:existing_user) { create(:user, password: 'Password1!') }
|
||||||
|
|
||||||
|
it 'returns the full response with account_id' do
|
||||||
|
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
|
||||||
|
post api_v1_accounts_url,
|
||||||
|
params: { account_name: 'Second Account', email: existing_user.email,
|
||||||
|
user_full_name: existing_user.name, password: 'Password1!' },
|
||||||
|
headers: existing_user.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.parsed_body.dig('data', 'account_id')).to be_present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
|
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to false' do
|
||||||
it 'responds 404 on requests' do
|
it 'responds 404 on requests' do
|
||||||
params = { account_name: 'test', email: email, user_full_name: user_full_name }
|
params = { account_name: 'test', email: email, user_full_name: user_full_name }
|
||||||
@@ -105,7 +122,17 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
context 'when ENABLE_ACCOUNT_SIGNUP env variable is set to api_only' do
|
||||||
it 'does not respond 404 on requests' do
|
before do
|
||||||
|
GlobalConfig.clear_cache
|
||||||
|
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
InstallationConfig.where(name: 'ENABLE_ACCOUNT_SIGNUP').delete_all
|
||||||
|
GlobalConfig.clear_cache
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns auth headers and full response for api_only signup' do
|
||||||
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||||
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
|
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
|
||||||
post api_v1_accounts_url,
|
post api_v1_accounts_url,
|
||||||
@@ -113,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
as: :json
|
as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when CW_API_ONLY_SERVER is true' do
|
||||||
|
it 'returns auth headers and full response' do
|
||||||
|
params = { account_name: 'test', email: email, user_full_name: user_full_name, password: 'Password1!' }
|
||||||
|
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true', CW_API_ONLY_SERVER: 'true' do
|
||||||
|
post api_v1_accounts_url,
|
||||||
|
params: params,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Resend Confirmations API', type: :request do
|
||||||
|
describe 'POST /resend_confirmation' do
|
||||||
|
let(:email) { 'unconfirmed@example.com' }
|
||||||
|
|
||||||
|
context 'when the user exists and is unconfirmed' do
|
||||||
|
before { create(:user, email: email, skip_confirmation: false) }
|
||||||
|
|
||||||
|
it 'sends confirmation instructions and returns 200' do
|
||||||
|
expect do
|
||||||
|
post '/resend_confirmation', params: { email: email }, as: :json
|
||||||
|
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the user exists and is already confirmed' do
|
||||||
|
before { create(:user, email: email) }
|
||||||
|
|
||||||
|
it 'returns 200 without sending confirmation' do
|
||||||
|
expect do
|
||||||
|
post '/resend_confirmation', params: { email: email }, as: :json
|
||||||
|
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when the email does not exist' do
|
||||||
|
it 'returns 200 without leaking email existence' do
|
||||||
|
post '/resend_confirmation', params: { email: 'nobody@example.com' }, as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when hCaptcha is configured' do
|
||||||
|
before do
|
||||||
|
create(:user, email: email, skip_confirmation: false)
|
||||||
|
allow(ChatwootCaptcha).to receive(:new).and_return(captcha)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid captcha response' do
|
||||||
|
let(:captcha) { instance_double(ChatwootCaptcha, valid?: true) }
|
||||||
|
|
||||||
|
it 'sends confirmation instructions' do
|
||||||
|
expect do
|
||||||
|
post '/resend_confirmation',
|
||||||
|
params: { email: email, h_captcha_client_response: 'valid-token' },
|
||||||
|
as: :json
|
||||||
|
end.to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an invalid captcha response' do
|
||||||
|
let(:captcha) { instance_double(ChatwootCaptcha, valid?: false) }
|
||||||
|
|
||||||
|
it 'returns 200 without sending confirmation' do
|
||||||
|
expect do
|
||||||
|
post '/resend_confirmation',
|
||||||
|
params: { email: email, h_captcha_client_response: 'bad-token' },
|
||||||
|
as: :json
|
||||||
|
end.not_to have_enqueued_mail(Devise::Mailer, :confirmation_instructions)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user