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:
Shivam Mishra
2026-04-07 13:45:17 +05:30
committed by GitHub
parent fbe3560b7a
commit 4f94ad4a75
11 changed files with 316 additions and 12 deletions

View File

@@ -31,8 +31,18 @@ class Api::V1::AccountsController < Api::BaseController
user: current_user
).perform
if @user
# Authenticated users (dashboard "add account") and api_only signups
# 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
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -103,6 +113,15 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
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
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end

View 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

View File

@@ -45,6 +45,13 @@
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"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."
}
}
}

View File

@@ -57,7 +57,6 @@ export const register = async creds => {
password: creds.password,
h_captcha_client_response: creds.hCaptchaClientResponse,
});
setAuthCredentials(response);
return response.data;
} catch (error) {
throwErrorMessage(error);
@@ -65,6 +64,13 @@ export const register = async creds => {
return null;
};
export const resendConfirmation = async ({ email, hCaptchaClientResponse }) => {
return wootAPI.post('resend_confirmation', {
email,
h_captcha_client_response: hCaptchaClientResponse,
});
};
export const verifyPasswordToken = async ({ confirmationToken }) => {
try {
const response = await wootAPI.post('auth/confirmation', {

View File

@@ -4,8 +4,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -19,6 +19,7 @@ const MIN_PASSWORD_LENGTH = 6;
const store = useStore();
const { t } = useI18n();
const router = useRouter();
const hCaptcha = ref(null);
const isPasswordFocused = ref(false);
@@ -76,7 +77,10 @@ const performRegistration = async () => {
isSignupInProgress.value = true;
try {
await register(credentials);
window.location = DEFAULT_REDIRECT_URL;
router.push({
name: 'auth_verify_email',
state: { email: credentials.email },
});
} catch (error) {
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
if (globalConfig.value.hCaptchaSiteKey) {

View 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>

View File

@@ -5,6 +5,7 @@ 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';
import VerifyEmail from './auth/verify-email/Index.vue';
import PasswordEdit from './auth/password/Edit.vue';
export default [
@@ -48,6 +49,15 @@ export default [
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'),
name: 'auth_password_edit',

View File

@@ -120,8 +120,20 @@ class Rack::Attack
end
end
## Resend confirmation throttling
## Resend confirmation throttling (unauthenticated)
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?
end

View File

@@ -8,6 +8,8 @@ Rails.application.routes.draw do
omniauth_callbacks: 'devise_overrides/omniauth_callbacks'
}, via: [:get, :post]
post 'resend_confirmation', to: 'auth/resend_confirmations#create'
## 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))
root to: 'api#index'

View File

@@ -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(account_builder).to have_received(:perform)
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.body).to include('en')
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
@@ -46,8 +46,8 @@ RSpec.describe 'Accounts API', type: :request do
as: :json
expect(ChatwootCaptcha).to have_received(:new).with('123')
expect(response.headers.keys).to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.body).to include('en')
expect(response.headers.keys).not_to include('access-token', 'token-type', 'client', 'expiry', 'uid')
expect(response.parsed_body['email']).to eq(email)
end
end
@@ -68,6 +68,23 @@ RSpec.describe 'Accounts API', type: :request do
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
it 'responds 404 on requests' do
params = { account_name: 'test', email: email, user_full_name: user_full_name }
@@ -105,7 +122,17 @@ RSpec.describe 'Accounts API', type: :request do
end
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!' }
with_modified_env ENABLE_ACCOUNT_SIGNUP: 'api_only' do
post api_v1_accounts_url,
@@ -113,6 +140,21 @@ RSpec.describe 'Accounts API', type: :request do
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
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

View File

@@ -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