Skip to content

Commit 50faa3c

Browse files
authored
Show warning in Contact Support form (/contact) when user is not on allow list (#835)
1 parent 1e0d831 commit 50faa3c

17 files changed

Lines changed: 244 additions & 10 deletions

File tree

assets/app/vue/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,8 @@
473473
"requiredFieldsText": "Fields marked with an asterisk (*) are required.",
474474
"errorFetchingFields": "Failed to fetch ticket fields. Please reload the page and try again.",
475475
"errorSubmittingForm": "Failed to submit contact form. Please try again.",
476+
"emailNotOnAllowList": "You don't have an account with us yet. Want to create one? {joinWaitlist}.",
477+
"joinWaitlist": "Join the waitlist",
476478
"success": "Your support request has been submitted successfully",
477479
"emailAddressLabel": "Your email address",
478480
"nameLabel": "Preferred name",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const isEmailInAllowList = async (email: string) => {
2+
const response = await fetch('/api/v1/contact/check-email-is-on-allow-list/', {
3+
method: 'POST',
4+
body: JSON.stringify({
5+
email,
6+
}),
7+
headers: {
8+
'Content-Type': 'application/json',
9+
'X-CSRFToken': window._page?.csrfToken,
10+
},
11+
});
12+
13+
return await response.json();
14+
}

assets/app/vue/views/ContactView/components/ContactSupportForm.vue

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<script setup lang="ts">
2-
import { ref, onMounted, useTemplateRef } from 'vue';
2+
import { ref, onMounted, useTemplateRef, nextTick } from 'vue';
33
import { useI18n } from 'vue-i18n';
4+
import { useDebounceFn } from '@vueuse/core';
45
import { NoticeBar, TextInput, TextArea, SelectInput, PrimaryButton, NoticeBarTypes, CheckboxInput, LoadingSkeleton } from '@thunderbirdops/services-ui';
56
import CsrfToken from '@/components/forms/CsrfToken.vue';
67
import NativeInputWrapper from './NativeInputWrapper.vue';
8+
import { isEmailInAllowList } from '../api';
79
810
// Types
911
import { ContactFieldsAPIResponse, TicketField } from '../types';
@@ -30,14 +32,16 @@ const { t } = useI18n();
3032
3133
const csrfTokenVal = ref(window._page.csrfToken);
3234
const errorText = ref(window._page.formError);
35+
const userEmailInAllowList = ref(true);
3336
const successText = ref('');
3437
const isSubmitting = ref(false);
38+
const tbProWaitListUrl = window._page?.tbProWaitListUrl;
3539
const form = ref({
3640
email: window._page?.userEmail || '',
3741
name: window._page?.userFullName || '',
3842
attachments: [],
3943
});
40-
const formRef = ref(null);
44+
const formRef = ref<HTMLFormElement | null>(null);
4145
const fileInput = ref(null);
4246
const isLoadingFormFields = ref(true);
4347
@@ -144,9 +148,26 @@ const resetForm = () => {
144148
formRef.value.reset();
145149
};
146150
151+
const checkEmailInAllowList = useDebounceFn(async () => {
152+
await nextTick();
153+
154+
const emailField = formRef.value?.elements.namedItem('email') as HTMLInputElement | null;
155+
156+
if (emailField?.checkValidity()) {
157+
const { is_on_allow_list } = await isEmailInAllowList(emailField.value.trim());
158+
userEmailInAllowList.value = is_on_allow_list;
159+
}
160+
}, 250);
161+
147162
const handleSubmit = async () => {
148163
if (isSubmitting.value) return;
149164
165+
await checkEmailInAllowList();
166+
167+
if (!userEmailInAllowList.value) {
168+
return;
169+
}
170+
150171
errorText.value = '';
151172
successText.value = '';
152173
@@ -224,11 +245,27 @@ onMounted(() => {
224245
<template>
225246
<notice-bar :type="NoticeBarTypes.Critical" v-if="errorText" class="notice">{{ errorText }}</notice-bar>
226247
<notice-bar :type="NoticeBarTypes.Success" v-if="successText" class="notice">{{ successText }}</notice-bar>
248+
<notice-bar :type="NoticeBarTypes.Warning" v-if="!userEmailInAllowList" class="notice">
249+
<i18n-t keypath="views.contact.emailNotOnAllowList" tag="span">
250+
<template #joinWaitlist>
251+
<a :href="tbProWaitListUrl" target="_blank">
252+
{{ t('views.contact.joinWaitlist') }}
253+
</a>
254+
</template>
255+
</i18n-t>
256+
</notice-bar>
227257

228258
<form @submit.prevent="handleSubmit" method="post" action="/contact/submit" ref="formRef">
229259
<!-- Email (always present) -->
230-
<text-input ref="emailInput" name="email" type="email" v-model="form.email" :required="true"
231-
data-testid="contact-email-input">
260+
<text-input
261+
ref="emailInput"
262+
name="email"
263+
type="email"
264+
v-model="form.email"
265+
:required="true"
266+
data-testid="contact-email-input"
267+
@input="checkEmailInAllowList"
268+
>
232269
{{ t('views.contact.emailAddressLabel') }}
233270
</text-input>
234271

@@ -287,7 +324,11 @@ onMounted(() => {
287324
</ul>
288325

289326
<div class="form-group">
290-
<primary-button @click.capture="handleSubmit" data-testid="contact-submit-btn" :disabled="isSubmitting">
327+
<primary-button
328+
@click.capture="handleSubmit"
329+
data-testid="contact-submit-btn"
330+
:disabled="isSubmitting"
331+
>
291332
{{ isSubmitting ? t('views.contact.submitting') : t('views.contact.submit') }}
292333
</primary-button>
293334
</div>
@@ -385,6 +426,10 @@ form {
385426
386427
.notice {
387428
margin-block: 1rem;
429+
430+
a {
431+
color: var(--colour-ti-highlight);
432+
}
388433
}
389434
390435
.attachment-list {

env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ declare global {
3636
maxEmailAliases?: number;
3737
tbProAppointmentUrl?: string;
3838
tbProSendUrl?: string;
39+
tbProWaitListUrl?: string;
3940
tbProPrimaryDomain?: string;
4041
currentView?: Record<string, any>;
4142
serverMessages: ServerMessage[];

src/thunderbird_accounts/authentication/tests/test_utils.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11

2-
from django.test import TestCase
3-
2+
from django.test import TestCase, override_settings
43

4+
from thunderbird_accounts.authentication.models import AllowListEntry, User
55
from thunderbird_accounts.authentication.reserved import is_reserved, servers, support
6+
from thunderbird_accounts.authentication.utils import is_email_in_allow_list
67

78
class IsReservedUnitTests(TestCase):
89
def test_brand_names(self):
@@ -93,3 +94,48 @@ def test_partial_matches_should_pass(self):
9394
# Anchors ^...$ mean full-string match only
9495
for name in ['user123', 'myusernamex', 'rooted', 'teamwork', 'contacting']:
9596
self.assertFalse(is_reserved(name))
97+
98+
99+
@override_settings(USE_ALLOW_LIST=True)
100+
class IsEmailInAllowListUnitTests(TestCase):
101+
def test_returns_true_for_active_user_email(self):
102+
User.objects.create(
103+
username='active-user@example.com',
104+
email='active-user@example.com',
105+
recovery_email='recovery@example.com',
106+
is_active=True,
107+
)
108+
109+
self.assertTrue(is_email_in_allow_list('active-user@example.com'))
110+
111+
def test_returns_true_for_active_user_recovery_email(self):
112+
User.objects.create(
113+
username='active-recovery@example.com',
114+
email='active-recovery@example.com',
115+
recovery_email='active-recovery-contact@example.com',
116+
is_active=True,
117+
)
118+
119+
self.assertTrue(is_email_in_allow_list('active-recovery-contact@example.com'))
120+
121+
def test_returns_true_for_allow_list_entry(self):
122+
AllowListEntry.objects.create(email='allow-listed@example.com')
123+
124+
self.assertTrue(is_email_in_allow_list('allow-listed@example.com'))
125+
126+
def test_returns_false_for_inactive_user_without_allow_list_entry(self):
127+
User.objects.create(
128+
username='inactive-user@example.com',
129+
email='inactive-user@example.com',
130+
recovery_email='inactive-recovery@example.com',
131+
is_active=False,
132+
)
133+
134+
self.assertFalse(is_email_in_allow_list('inactive-user@example.com'))
135+
136+
def test_returns_false_when_email_is_missing_from_user_and_allow_list(self):
137+
self.assertFalse(is_email_in_allow_list('missing@example.com'))
138+
139+
@override_settings(USE_ALLOW_LIST=False)
140+
def test_returns_true_when_allow_list_is_disabled(self):
141+
self.assertTrue(is_email_in_allow_list('not-listed@example.com'))

src/thunderbird_accounts/authentication/utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import sentry_sdk
66
from django.conf import settings
7+
from django.db.models import Q
78
from django.urls import reverse
89

910
from thunderbird_accounts.core.utils import get_absolute_url
@@ -53,7 +54,12 @@ def is_email_in_allow_list(email: str):
5354
return True
5455

5556
# Are they an existing active user?
56-
user = User.objects.filter(email=email).filter(is_active=True).first()
57+
# Matching by email which is the thundermail address or the recovery email
58+
user = User.objects.filter(
59+
Q(email=email) | Q(recovery_email=email),
60+
is_active=True,
61+
).first()
62+
5763
if not user:
5864
try:
5965
# Make sure they're on the allow list

src/thunderbird_accounts/core/templates/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
maxEmailAliases: {{ max_email_aliases|to_json|safe }},
4343
tbProAppointmentUrl: {{ tb_pro_appointment_url|to_json|safe }},
4444
tbProSendUrl: {{ tb_pro_send_url|to_json|safe }},
45+
tbProWaitListUrl: {{ tb_pro_wait_list_url|to_json|safe }},
4546
tbProPrimaryDomain: {{ tb_pro_primary_domain|to_json|safe }},
4647
serverMessages: {{ server_messages|default:None|to_json|safe }},
4748
features: {{ features|safe|default:'{}' }},

src/thunderbird_accounts/core/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def home(request: HttpRequest):
143143
'max_email_aliases': max_email_aliases,
144144
'tb_pro_appointment_url': settings.TB_PRO_APPOINTMENT_URL,
145145
'tb_pro_send_url': settings.TB_PRO_SEND_URL,
146+
'tb_pro_wait_list_url': settings.TB_PRO_WAIT_LIST_URL,
146147
'tb_pro_primary_domain': settings.PRIMARY_EMAIL_DOMAIN,
147148
'server_messages': [
148149
{'level': message.level, 'message': str(message.message)} for message in get_messages(request)

src/thunderbird_accounts/mail/api.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from thunderbird_accounts.authentication.utils import is_email_reserved
1+
from thunderbird_accounts.authentication.utils import is_email_reserved, is_email_in_allow_list
22
from thunderbird_accounts.mail.exceptions import EmailNotValidError
33
from thunderbird_accounts.mail.utils import validate_email
44
from rest_framework.permissions import AllowAny
@@ -18,6 +18,10 @@ class UsernameAvailableThrottle(UserRateThrottle):
1818
scope = 'is_username_available'
1919

2020

21+
class CheckEmailIsOnAllowListThrottle(UserRateThrottle):
22+
scope = 'check_email_is_on_allow_list'
23+
24+
2125
@api_view(['POST'])
2226
@permission_classes([AllowAny])
2327
@throttle_classes([UsernameAvailableThrottle])
@@ -43,3 +47,21 @@ def is_username_available(request: Request):
4347
raise ValidationError(_('This username is already taken. Try another one.'))
4448

4549
return Response(status=200)
50+
51+
52+
@api_view(['POST'])
53+
@permission_classes([AllowAny])
54+
@throttle_classes([CheckEmailIsOnAllowListThrottle])
55+
def check_email_is_on_allow_list(request: Request):
56+
if not settings.CONTACT_SUPPORT_ONLY_FOR_ALLOW_LISTED_USERS:
57+
return Response({
58+
'is_on_allow_list': True,
59+
}, status=200)
60+
61+
email = request.data.get('email')
62+
if not email:
63+
raise ValidationError(_('You need to enter an email address.'))
64+
65+
return Response({
66+
'is_on_allow_list': is_email_in_allow_list(email),
67+
}, status=200)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from unittest.mock import patch
2+
3+
from django.test import override_settings, Client as RequestClient
4+
from rest_framework.test import APITestCase
5+
6+
7+
@override_settings(CONTACT_SUPPORT_ONLY_FOR_ALLOW_LISTED_USERS=True)
8+
class CheckEmailIsOnAllowListApiTestCase(APITestCase):
9+
def setUp(self):
10+
self.client = RequestClient()
11+
self.url = '/api/v1/contact/check-email-is-on-allow-list/'
12+
13+
def test_missing_email_returns_validation_error(self):
14+
response = self.client.post(self.url, {})
15+
16+
self.assertEqual(response.status_code, 400)
17+
self.assertIn('You need to enter an email address.', str(response.json()))
18+
19+
@patch('thunderbird_accounts.mail.api.is_email_in_allow_list')
20+
def test_returns_true_when_email_is_on_allow_list(self, mock_is_email_in_allow_list):
21+
mock_is_email_in_allow_list.return_value = True
22+
email = 'allowed@example.com'
23+
24+
response = self.client.post(self.url, {'email': email})
25+
26+
self.assertEqual(response.status_code, 200)
27+
self.assertEqual(response.json(), {'is_on_allow_list': True})
28+
mock_is_email_in_allow_list.assert_called_once_with(email)
29+
30+
@patch('thunderbird_accounts.mail.api.is_email_in_allow_list')
31+
def test_returns_false_when_email_is_not_on_allow_list(self, mock_is_email_in_allow_list):
32+
mock_is_email_in_allow_list.return_value = False
33+
email = 'not-allowed@example.com'
34+
35+
response = self.client.post(self.url, {'email': email})
36+
37+
self.assertEqual(response.status_code, 200)
38+
self.assertEqual(response.json(), {'is_on_allow_list': False})
39+
mock_is_email_in_allow_list.assert_called_once_with(email)
40+
41+
@override_settings(CONTACT_SUPPORT_ONLY_FOR_ALLOW_LISTED_USERS=False)
42+
@patch('thunderbird_accounts.mail.api.is_email_in_allow_list')
43+
def test_returns_true_without_checking_allow_list_when_restriction_is_disabled(
44+
self,
45+
mock_is_email_in_allow_list,
46+
):
47+
response = self.client.post(self.url, {})
48+
49+
self.assertEqual(response.status_code, 200)
50+
self.assertEqual(response.json(), {'is_on_allow_list': True})
51+
mock_is_email_in_allow_list.assert_not_called()

0 commit comments

Comments
 (0)