diff --git a/zubhub_backend/docker-compose.yml b/zubhub_backend/docker-compose.yml index 505991bb1..61882ebc1 100644 --- a/zubhub_backend/docker-compose.yml +++ b/zubhub_backend/docker-compose.yml @@ -18,6 +18,9 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_HOST=${POSTGRES_HOST} - SENDGRID_API_KEY=${SENDGRID_API_KEY} + - DEFAULT_FROM_PHONE=${DEFAULT_FROM_PHONE} + - TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID} + - TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN} volumes: - .:/zubhub_backend ports: diff --git a/zubhub_backend/requirements.txt b/zubhub_backend/requirements.txt index 16076b798..1b0162bf2 100644 --- a/zubhub_backend/requirements.txt +++ b/zubhub_backend/requirements.txt @@ -59,6 +59,7 @@ psycopg2-binary==2.8.3 ptyprocess==0.6.0 py==1.9.0 Pygments==2.7.2 +PyJWT==1.7.1 pyparsing==2.4.7 pytest==5.3.5 pytest-django==3.8.0 @@ -75,6 +76,7 @@ sqlparse==0.4.1 text-unidecode==1.3 tornado==6.1 traitlets==5.0.5 +twilio==6.51.1 uritemplate==3.0.1 urllib3==1.25.11 vine==1.3.0 diff --git a/zubhub_backend/zubhub/creators/adapter.py b/zubhub_backend/zubhub/creators/adapter.py index a561cda39..c833e65b0 100644 --- a/zubhub_backend/zubhub/creators/adapter.py +++ b/zubhub_backend/zubhub/creators/adapter.py @@ -1,5 +1,14 @@ +from django.conf import settings +from django.template.loader import render_to_string from allauth.account.adapter import DefaultAccountAdapter -from .models import Location +from django.template import TemplateDoesNotExist +from twilio.rest import Client + +from .models import Location, Setting + +from creators.tasks import send_text + +from allauth.account import app_settings as allauth_settings class CustomAccountAdapter(DefaultAccountAdapter): @@ -9,7 +18,68 @@ def save_user(self, request, user, form, commit=False): data = form.cleaned_data location = Location.objects.get(name=data.get('location')) + creator.phone = data.get("phone") creator.dateOfBirth = data.get('dateOfBirth') + creator.bio = data.get('bio') creator.location = location creator.save() + + Setting(creator=creator, subscribe=data.get("subscribe")).save() + return creator + + def confirm_phone(self, request, phone_number): + """ + Marks the phone number as confirmed on the db + """ + phone_number.verified = True + phone_number.set_as_primary(conditional=True) + phone_number.save() + + def get_from_phone(self): + """ + This is a hook that can be overridden to programatically + set the 'from' phone number for sending phone texts messages + """ + return settings.DEFAULT_FROM_PHONE + + def render_text(self, template_name, phone, context): + """ + Renders a text to `text`. `template_prefix` identifies the + text that is to be sent, e.g. "account/phone/phone_confirmation" + """ + + from_phone = self.get_from_phone() + + try: + body = render_to_string( + template_name, + context, + self.request, + ).strip() + except TemplateDoesNotExist: + raise + + return {"to": phone, "from_": from_phone, "body": body} + + def send_text(self, template_name, phone, context): + + client = Client(settings.TWILIO_ACCOUNT_SID, + settings.TWILIO_AUTH_TOKEN) + text = self.render_text(template_name, phone, context) + client.messages.create(**text) + + def send_confirmation_text(self, request, phoneconfirmation, signup): + + ctx = { + "user": phoneconfirmation.phone_number.user.username, + "key": phoneconfirmation.key, + } + + template_name = "account/phone/phone_confirmation.txt" + + send_text.delay( + phone=phoneconfirmation.phone_number.phone, + template_name=template_name, + ctx=ctx, + ) diff --git a/zubhub_backend/zubhub/creators/admin.py b/zubhub_backend/zubhub/creators/admin.py index 8b356e8e7..8e0b4fa6f 100644 --- a/zubhub_backend/zubhub/creators/admin.py +++ b/zubhub_backend/zubhub/creators/admin.py @@ -1,11 +1,25 @@ from django.contrib import admin from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin +from .models import PhoneNumber, Setting Creator = get_user_model() + +class PhoneNumberAdmin(admin.ModelAdmin): + list_display = ["phone", "verified", "primary"] + search_fields = ["phone", "user__username"] + list_filter = ['verified', "primary"] + + +class SettingAdmin(admin.ModelAdmin): + list_filter = ["subscribe"] + + UserAdmin.fieldsets += ('Personal Info', {'fields': ('avatar', 'phone', 'dateOfBirth', 'location', 'bio')}), UserAdmin.readonly_fields += ("avatar"), admin.site.register(Creator, UserAdmin) +admin.site.register(PhoneNumber, PhoneNumberAdmin) +admin.site.register(Setting, SettingAdmin) diff --git a/zubhub_backend/zubhub/creators/managers.py b/zubhub_backend/zubhub/creators/managers.py new file mode 100644 index 000000000..472efe6b8 --- /dev/null +++ b/zubhub_backend/zubhub/creators/managers.py @@ -0,0 +1,61 @@ +from datetime import timedelta + +from django.db import models +from django.db.models import Q +from django.utils import timezone + + +class PhoneNumberManager(models.Manager): + # def can_add_phone(self, user): + # ret = True + # if app_settings.MAX_EMAIL_ADDRESSES: + # count = self.filter(user=user).count() + # ret = count < app_settings.MAX_EMAIL_ADDRESSES + # return ret + + def add_phone(self, request, user, phone, confirm=False, signup=False): + phone_number, created = self.get_or_create( + user=user, phone__iexact=phone, defaults={"phone": phone} + ) + + if created and confirm: + phone_number.send_confirmation(request, signup=signup) + + return phone_number + + def get_primary(self, user): + try: + return self.get(user=user, primary=True) + except self.model.DoesNotExist: + return None + + def get_users_for(self, phone): + # this is a list rather than a generator because we probably want to + # do a len() on it right away + return [ + phone_number.user for phone_number in self.filter(verified=True, phone__iexact=phone) + ] + + def fill_cache_for_user(self, user, phone_numbers): + """ + In a multi-db setup, inserting records and re-reading them later + on may result in not being able to find newly inserted + records. Therefore, we maintain a cache for the user so that + we can avoid database access when we need to re-read.. + """ + user._phonenumber_cache = phone_numbers + + def get_for_user(self, user, phone): + cache_key = "_phonenumber_cache" + phone_numbers = getattr(user, cache_key, None) + if phone_numbers is None or (type(phone_numbers) == list and phone_numbers[0] is None): + ret = self.get(user=user, phone__iexact=phone) + # To avoid additional lookups when e.g. + # EmailAddress.set_as_primary() starts touching self.user + ret.user = user + return ret + else: + for phone_number in phone_numbers: + if phone_number.phone == phone: + return phone_number + raise self.model.DoesNotExist() diff --git a/zubhub_backend/zubhub/creators/models.py b/zubhub_backend/zubhub/creators/models.py index 0a667e528..ab0442f6d 100644 --- a/zubhub_backend/zubhub/creators/models.py +++ b/zubhub_backend/zubhub/creators/models.py @@ -1,9 +1,21 @@ import uuid from math import floor from django.utils import timezone +from django.core import signing +from django.utils.crypto import get_random_string +from django.utils.translation import gettext_lazy as _ from django.utils.text import slugify from django.contrib.auth.models import AbstractUser -from django.db import models +from django.db import models, transaction +from .managers import PhoneNumberManager +from .utils import user_phone +from . import signals + +try: + from allauth.account import app_settings as allauth_settings + from allauth.account.adapter import get_adapter +except ImportError: + raise ImportError("allauth needs to be added to INSTALLED_APPS.") class Location(models.Model): @@ -44,3 +56,114 @@ def save(self, *args, **kwargs): self.following_count = self.following.count() self.projects_count = self.projects.count() super().save(*args, **kwargs) + + +class Setting(models.Model): + creator = models.OneToOneField( + Creator, on_delete=models.CASCADE, primary_key=True) + subscribe = models.BooleanField(blank=True, default=False) + + def __str__(self): + return self.creator.username + + +class PhoneNumber(models.Model): + + user = models.ForeignKey( + Creator, + verbose_name="creator", + on_delete=models.CASCADE, + ) + phone = models.CharField( + unique=True, + max_length=16, + verbose_name="phone number", + ) + verified = models.BooleanField(verbose_name="verified", default=False) + primary = models.BooleanField(verbose_name="primary", default=False) + + objects = PhoneNumberManager() + + class Meta: + verbose_name = "phone number" + verbose_name_plural = "phone numbers" + + def __str__(self): + return self.phone + + def set_as_primary(self, conditional=False): + old_primary = PhoneNumber.objects.get_primary(self.user) + if old_primary: + if conditional: + return False + old_primary.primary = False + old_primary.save() + self.primary = True + self.save() + user_phone(self.user, self.phone) + self.user.save() + return True + + def send_confirmation(self, request=None, signup=False): + confirmation = PhoneConfirmationHMAC(self) + confirmation.send(request, signup=signup) + return confirmation + + # def change(self, request, new_email, confirm=True): + # """ + # Given a new email address, change self and re-confirm. + # """ + # with transaction.atomic(): + # user_email(self.user, new_email) + # self.user.save() + # self.email = new_email + # self.verified = False + # self.save() + # if confirm: + # self.send_confirmation(request) + + +class PhoneConfirmationHMAC: + def __init__(self, phone_number): + self.phone_number = phone_number + + @property + def key(self): + return signing.dumps(obj=self.phone_number.pk, salt=allauth_settings.SALT) + + @classmethod + def from_key(cls, key): + PHONE_CONFIRMATION_EXPIRE_DAYS = 3 + try: + max_age = 60 * 60 * 24 * PHONE_CONFIRMATION_EXPIRE_DAYS + pk = signing.loads(key, max_age=max_age, + salt=allauth_settings.SALT) + ret = PhoneConfirmationHMAC(PhoneNumber.objects.get(pk=pk)) + except ( + signing.SignatureExpired, + signing.BadSignature, + PhoneNumber.DoesNotExist, + ): + ret = None + return ret + + def confirm(self, request): + + if not self.phone_number.verified: + phone_number = self.phone_number + get_adapter(request).confirm_phone(request, phone_number) + signals.phone_confirmed.send( + sender=self.__class__, + request=request, + phone_number=phone_number, + ) + return phone_number + + def send(self, request=None, signup=False): + get_adapter(request).send_confirmation_text(request, self, signup) + signals.phone_confirmation_sent.send( + sender=self.__class__, + request=request, + confirmation=self, + signup=signup, + ) diff --git a/zubhub_backend/zubhub/creators/serializers.py b/zubhub_backend/zubhub/creators/serializers.py index 9a47d4c22..6b35b5a6b 100644 --- a/zubhub_backend/zubhub/creators/serializers.py +++ b/zubhub_backend/zubhub/creators/serializers.py @@ -1,16 +1,22 @@ from datetime import date +import re from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers from django.contrib.auth import get_user_model -from .models import Location +from .models import Location, PhoneNumber, Setting +from allauth.account.models import EmailAddress from rest_auth.registration.serializers import RegisterSerializer +from allauth.account.utils import setup_user_email +from .utils import setup_user_phone Creator = get_user_model() class CreatorSerializer(serializers.ModelSerializer): id = serializers.UUIDField(read_only=True) + phone = serializers.CharField(allow_blank=True, default="") + email = serializers.EmailField(allow_blank=True, default="") followers = serializers.SlugRelatedField( slug_field="id", read_only=True, many=True) location = serializers.SlugRelatedField( @@ -18,9 +24,61 @@ class CreatorSerializer(serializers.ModelSerializer): class Meta: model = Creator - fields = ('id', 'username', 'email', 'avatar', 'location', + fields = ('id', 'username', 'email', 'phone', 'avatar', 'location', 'dateOfBirth', 'bio', 'followers', 'following_count', 'projects_count') + def validate_email(self, email): + + if(len(email) == 0 and len(self.initial_data.get("phone", "")) == 0): + raise serializers.ValidationError( + _("you must provide either email or phone number")) + + if(len(Creator.objects.filter(email=email)) > 0 and email != ""): + if email == self.context.get("request").user.email: + return email + raise serializers.ValidationError( + _("a user with that email address already exists")) + + if(self.context.get("request").user.email): + raise serializers.ValidationError( + _("to edit this field mail hello@unstructured.studio")) + + return email + + def validate_phone(self, phone): + if(len(phone) == 0 and len(self.initial_data.get("email", "")) == 0): + raise serializers.ValidationError( + _("you must provide either email or phone number")) + + if re.search(r'^\+\d{9,15}$', phone) == None and phone != "": + raise serializers.ValidationError( + _("Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.")) + + if(len(Creator.objects.filter(phone=phone)) > 0 and phone != ""): + if phone == self.context.get("request").user.phone: + return phone + raise serializers.ValidationError( + _("a user with that phone number already exists")) + + if(self.context.get("request").user.phone): + raise serializers.ValidationError( + _("to edit this field mail hello@unstructured.studio")) + + return phone + + def update(self, user, validated_data): + creator = super().update(user, validated_data) + phone_number = PhoneNumber.objects.filter(user=creator) + email_address = EmailAddress.objects.filter(user=creator) + + if(len(phone_number) < 1): + setup_user_phone(creator) + + if(len(email_address) < 1): + setup_user_email(self.context.get("request"), creator, []) + + return creator + class LocationSerializer(serializers.ModelSerializer): class Meta: @@ -29,18 +87,39 @@ class Meta: class CustomRegisterSerializer(RegisterSerializer): + phone = serializers.CharField(allow_blank=True, default="") + email = serializers.EmailField(allow_blank=True, default="") dateOfBirth = serializers.DateField() location = serializers.SlugRelatedField( slug_field='name', queryset=Location.objects.all()) + bio = serializers.CharField(allow_blank=True, default="", max_length=255) + subscribe = serializers.BooleanField(default=False) + + def validate_email(self, email): + if(len(email) == 0 and len(self.initial_data.get("phone", "")) == 0): + raise serializers.ValidationError( + _("you must provide either email or phone number")) - # def validate_phone(self, phone): - # if re.search(r'^\+\d{9,15}$', phone) == None: - # raise serializers.ValidationError( - # _("Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.")) - # if(len(Creator.objects.filter(phone=phone)) > 0): - # raise serializers.ValidationError( - # _("A user with that phone number already exists")) - # return phone + if(len(Creator.objects.filter(email=email)) > 0 and email != ""): + raise serializers.ValidationError( + _("A user with that email address already exists")) + + return email + + def validate_phone(self, phone): + if(len(phone) == 0 and len(self.initial_data.get("email", "")) == 0): + raise serializers.ValidationError( + _("you must provide either email or phone number")) + + if re.search(r'^\+\d{9,15}$', phone) == None and phone != "": + raise serializers.ValidationError( + _("Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.")) + + if(len(Creator.objects.filter(phone=phone)) > 0 and phone != ""): + raise serializers.ValidationError( + _("A user with that phone number already exists")) + + return phone def validate_dateOfBirth(self, dateOfBirth): if((date.today() - dateOfBirth).days < 0): @@ -55,7 +134,19 @@ def validate_location(self, location): def get_cleaned_data(self): data_dict = super().get_cleaned_data() + data_dict['phone'] = self.validated_data.get('phone', '') data_dict['dateOfBirth'] = self.validated_data.get('dateOfBirth', '') data_dict['location'] = self.validated_data.get('location', '') + data_dict['bio'] = self.validated_data.get('bio', '') + data_dict['subscribe'] = self.validated_data.get('subscribe', '') return data_dict + + def save(self, request): + creator = super().save(request) + setup_user_phone(creator) + return creator + + +class VerifyPhoneSerializer(serializers.Serializer): + key = serializers.CharField() diff --git a/zubhub_backend/zubhub/creators/signals.py b/zubhub_backend/zubhub/creators/signals.py index e69de29bb..a2cceb96c 100644 --- a/zubhub_backend/zubhub/creators/signals.py +++ b/zubhub_backend/zubhub/creators/signals.py @@ -0,0 +1,6 @@ +from django.dispatch import Signal + +# Provides the arguments "request", "phone_number" +phone_confirmed = Signal() +# Provides the arguments "request", "confirmation", "signup" +phone_confirmation_sent = Signal() diff --git a/zubhub_backend/zubhub/creators/tasks.py b/zubhub_backend/zubhub/creators/tasks.py new file mode 100644 index 000000000..63829ddbf --- /dev/null +++ b/zubhub_backend/zubhub/creators/tasks.py @@ -0,0 +1,16 @@ +from celery import shared_task +from random import uniform + +try: + from allauth.account.adapter import get_adapter +except ImportError: + raise ImportError("allauth needs to be added to INSTALLED_APPS.") + + +@shared_task(name="creators.tasks.send_text", bind=True, acks_late=True, max_retries=10) +def send_text(self, phone, template_name, ctx): + try: + get_adapter().send_text(template_name, phone, ctx) + except Exception as e: + raise self.retry(exc=e, countdown=int( + uniform(2, 4) ** self.request.retries)) diff --git a/zubhub_backend/zubhub/creators/urls.py b/zubhub_backend/zubhub/creators/urls.py index 7f28060bc..a667cd6f2 100644 --- a/zubhub_backend/zubhub/creators/urls.py +++ b/zubhub_backend/zubhub/creators/urls.py @@ -4,6 +4,8 @@ app_name = "creators" urlpatterns = [ + path('register/', RegisterCreatorAPIView.as_view(), name="signup_creator"), + path('verify-phone/', VerifyPhoneView.as_view(), name="verify_phone"), path('authUser/', auth_user_api_view, name='auth_user_detail'), path('edit_creator/', EditCreatorAPIView.as_view(), name='edit_creator'), path('delete/', DeleteCreatorAPIView.as_view(), name='delete_creator'), diff --git a/zubhub_backend/zubhub/creators/utils.py b/zubhub_backend/zubhub/creators/utils.py new file mode 100644 index 000000000..3e4a881b1 --- /dev/null +++ b/zubhub_backend/zubhub/creators/utils.py @@ -0,0 +1,170 @@ +import unicodedata +from django.conf import settings +from django.contrib.auth import update_session_auth_hash +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import models +from django.db.models import Q +from django.utils.encoding import force_str +from django.utils.http import base36_to_int, int_to_base36, urlencode +from django.contrib.auth import get_user_model + + +try: + from allauth.account.adapter import get_adapter + from allauth.account.utils import send_email_confirmation + from allauth.account.models import EmailAddress +except ImportError: + raise ImportError("allauth needs to be added to INSTALLED_APPS.") + + +def user_field(user, field, *args): + """ + Gets or sets (optional) user model fields. No-op if fields do not exist. + """ + if not field: + return + User = get_user_model() + try: + field_meta = User._meta.get_field(field) + max_length = field_meta.max_length + except FieldDoesNotExist: + if not hasattr(user, field): + return + max_length = None + if args: + # Setter + v = args[0] + if v: + v = v[0:max_length] + setattr(user, field, v) + else: + # Getter + return getattr(user, field) + + +def user_phone(user, *args): + return user_field(user, "phone", *args) + + +def _has_verified_phone_for_login(user, phone): + from .models import PhoneNumber + + phonenumber = None + if phone: + ret = False + try: + phonenumber = PhoneNumber.objects.get_for_user(user, phone) + ret = phonenumber.verified + except PhoneNumber.DoesNotExist: + pass + else: + ret = PhoneNumber.objects.filter(user=user, verified=True).exists() + return ret + + +def _has_verified_email_for_login(user, email): + emailaddress = None + if email: + ret = False + try: + emailaddress = EmailAddress.objects.get_for_user(user, email) + ret = emailaddress.verified + except EmailAddress.DoesNotExist: + pass + else: + ret = EmailAddress.objects.filter(user=user, verified=True).exists() + return ret + + +def perform_send_phone_confirmation( + request, + user, + signup=False, + phone=None, +): + adapter = get_adapter(request) + if not user.is_active: + return adapter.respond_user_inactive(request, user) + + if not _has_verified_phone_for_login(user, phone): + send_phone_confirmation(request, user, signup=signup, phone=phone) + + +def perform_send_email_confirmation(request, user, signup=False, email=None): + adapter = get_adapter(request) + if not user.is_active: + return adapter.respond_user_inactive(request, user) + + if not _has_verified_email_for_login(user, email): + send_email_confirmation( + request, user, signup=signup) + + +def setup_user_phone(user): + """ + Creates proper PhoneNumber for the user that was just signed + up. Only sets up, doesn't do any other handling such as sending + out phone number confirmation texts etc. + """ + from .models import PhoneNumber + + assert not PhoneNumber.objects.filter(user=user).exists() + + phone = user_phone(user) + phone_number = None + if phone: + phone_number = PhoneNumber( + user=user, phone=phone, primary=True, verified=False) + phone_number.save() + + if phone_number: + PhoneNumber.objects.fill_cache_for_user(user, [phone_number]) + return phone_number + + +def send_phone_confirmation(request, user, signup=False, phone=None): + """ + Phone Number verification texts are sent: + a) Explicitly: when a user signs up + + """ + from .models import PhoneNumber + + if not phone: + phone = user_phone(user) + if phone: + try: + phone_number = PhoneNumber.objects.get_for_user(user, phone) + if not phone_number.verified: + phone_number.send_confirmation(request, signup=signup) + except PhoneNumber.DoesNotExist: + phone_number = PhoneNumber.objects.add_phone( + request, user, phone, signup=signup, confirm=True + ) + assert phone_number + + +# def sync_user_email_addresses(user): +# """ +# Keep user.email in sync with user.emailaddress_set. + +# Under some circumstances the user.email may not have ended up as +# an EmailAddress record, e.g. in the case of manually created admin +# users. +# """ +# from .models import EmailAddress + +# email=user_email(user) +# if ( +# email +# and not EmailAddress.objects.filter(user=user, email__iexact=email).exists() +# ): +# if ( +# app_settings.UNIQUE_EMAIL +# and EmailAddress.objects.filter(email__iexact=email).exists() +# ): +# # Bail out +# return +# EmailAddress.objects.create( +# user=user, email=email, primary=False, verified=False +# ) diff --git a/zubhub_backend/zubhub/creators/views.py b/zubhub_backend/zubhub/creators/views.py index 20039579a..9595eacc8 100644 --- a/zubhub_backend/zubhub/creators/views.py +++ b/zubhub_backend/zubhub/creators/views.py @@ -1,16 +1,24 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import status +from django.http import Http404 +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 from rest_framework.decorators import api_view, permission_classes +from rest_framework.views import APIView from rest_framework.generics import UpdateAPIView, RetrieveAPIView, ListAPIView, DestroyAPIView from rest_framework.permissions import IsAuthenticated, AllowAny, IsAuthenticatedOrReadOnly from rest_framework.response import Response +from rest_auth.registration.views import RegisterView from projects.serializers import ProjectListSerializer from projects.pagination import ProjectNumberPagination -from .serializers import CreatorSerializer, LocationSerializer -from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404 +from .serializers import CreatorSerializer, LocationSerializer, VerifyPhoneSerializer, CustomRegisterSerializer from .permissions import IsOwner from .models import Location from .pagination import CreatorNumberPagination +from .utils import perform_send_phone_confirmation, perform_send_email_confirmation + +from .models import PhoneConfirmationHMAC Creator = get_user_model() @@ -28,14 +36,58 @@ class UserProfileAPIView(RetrieveAPIView): lookup_field = "username" permission_classes = [AllowAny] + # perform_send_phone_confirmation + + +class RegisterCreatorAPIView(RegisterView): + serializer_class = CustomRegisterSerializer + + def perform_create(self, serializer): + creator = super().perform_create(serializer) + perform_send_phone_confirmation( + self.request._request, creator, signup=True) + return creator + + +class VerifyPhoneView(APIView): + permission_classes = (AllowAny,) + allowed_methods = ('POST', 'OPTIONS', 'HEAD') + + def get_serializer(self, *args, **kwargs): + return VerifyPhoneSerializer(*args, **kwargs) + + def get_object(self, queryset=None): + key = self.kwargs["key"] + phoneconfirmation = PhoneConfirmationHMAC.from_key(key) + if not phoneconfirmation: + raise Http404() + + return phoneconfirmation + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.kwargs['key'] = serializer.validated_data['key'] + confirmation = self.get_object() + confirmation.confirm(self.request) + return Response({'detail': _('ok')}, status=status.HTTP_200_OK) + class EditCreatorAPIView(UpdateAPIView): queryset = Creator.objects.all() serializer_class = CreatorSerializer permission_classes = [IsAuthenticated, IsOwner] - def patch(self, request, *args, **kwargs): - return self.update(request, *args, **kwargs) + def perform_update(self, serializer): + response = super().perform_update(serializer) + creator = Creator.objects.filter(pk=self.request.user.pk) + if len(creator) > 0: + perform_send_phone_confirmation( + self.request._request, creator[0], signup=True) + + perform_send_email_confirmation( + self.request._request, creator[0], signup=True) + return response def get_object(self): queryset = self.filter_queryset(self.get_queryset()) diff --git a/zubhub_backend/zubhub/projects/signals.py b/zubhub_backend/zubhub/projects/signals.py index 29d029d9c..626b48dd2 100644 --- a/zubhub_backend/zubhub/projects/signals.py +++ b/zubhub_backend/zubhub/projects/signals.py @@ -1,6 +1,6 @@ from django.db.models.signals import pre_delete, post_save from django.dispatch import receiver -from .tasks import delete_image_from_DO_space +from projects.tasks import delete_image_from_DO_space from .models import Project, Image diff --git a/zubhub_backend/zubhub/templates/account/phone/phone_confirmation.txt b/zubhub_backend/zubhub/templates/account/phone/phone_confirmation.txt new file mode 100644 index 000000000..dfcbae2f9 --- /dev/null +++ b/zubhub_backend/zubhub/templates/account/phone/phone_confirmation.txt @@ -0,0 +1,11 @@ +{% load account %} +{% load i18n %} +{% load default_template_tags %} +{% autoescape off %} +{% trans "Hello from " %}{% default_display_name %}! +{% blocktrans %} +You're receiving this text because user {{user}} has connected your phone number to their account. +{% endblocktrans %} +{% trans "To confirm this is correct, go to " %} {% default_frontend_protocol %}://{% default_frontend_domain %}/phone-confirm?user={{user}}&&key={{key}} +{% endautoescape %} +{% trans "Thank you from" %} {% default_display_name %}! \ No newline at end of file diff --git a/zubhub_backend/zubhub/zubhub/settings.py b/zubhub_backend/zubhub/zubhub/settings.py index 59777cd46..eca620afc 100644 --- a/zubhub_backend/zubhub/zubhub/settings.py +++ b/zubhub_backend/zubhub/zubhub/settings.py @@ -94,9 +94,9 @@ 'crispy_forms', 'debug_toolbar', 'mptt', - 'APIS.apps.ApisConfig', - 'creators.apps.CreatorsConfig', - 'projects.apps.ProjectsConfig', + 'APIS', + 'creators', + 'projects', ] @@ -274,6 +274,11 @@ EMAIL_PORT = 465 EMAIL_USE_SSL = True + +DEFAULT_FROM_PHONE = os.environ.get("DEFAULT_FROM_PHONE") +TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") +TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") + # EMAIL_PORT = 1025 CELERY_EMAIL_CHUNK_SIZE = 1 CELERY_EMAIL_TASK_CONFIG = { diff --git a/zubhub_frontend/zubhub/public/locales/en/translation.json b/zubhub_frontend/zubhub/public/locales/en/translation.json index 80dea64f3..027980961 100644 --- a/zubhub_frontend/zubhub/public/locales/en/translation.json +++ b/zubhub_frontend/zubhub/public/locales/en/translation.json @@ -47,8 +47,15 @@ "email": { "label": "Email", "errors": { - "invalid": "invalid email", - "required": "this field is required" + "phoneOrEmail": "you must provide either email or phone number", + "invalid": "invalid email" + } + }, + "phone": { + "label": "Phone", + "errors": { + "phoneOrEmail": "you must provide either email or phone number", + "invalid": "Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed." } }, "dateOfBirth": { @@ -79,8 +86,16 @@ "required": "this field is required" } }, + "bio": { + "label": "Bio", + "helpText": "Tell us something interesting about you! You can share what care about, your hobbies, things you have been working on recently, etc.", + "errors": { + "tooLong": "your bio shouldn't be more than 255 characters" + } + }, "submit": "Signup" }, + "unsubscribe": "I don't want to receive updates on new tinkering projects and ideas via WhatsApp/SMS or e-mail", "alreadyAMember": "Already a Member ?", "login": "Login", "errors": { @@ -185,6 +200,20 @@ } }, + "phoneConfirm": { + "welcomeMsg": { + "primary": "Phone Confirmation", + "secondary": " Please Confirm that you are <> and that the phone number you connected to your account belongs to you:" + }, + "inputs": { + "submit": "Confirm" + }, + "toastSuccess": "Congratulations!, your phone number has been confirmed!", + "errors": { + "unexpected": "An error occured while performing this action. Please try again later" + } + }, + "projects": { "prev": "Prev", "next": "Next", @@ -380,6 +409,20 @@ "required": "this field is required" } }, + "email": { + "label": "Email", + "disabledHelperText": "to edit this field mail admin@unstrucutred.studio", + "errors": { + "invalid": "invalid email" + } + }, + "phone": { + "label": "Phone", + "disabledHelperText": "to edit this field mail admin@unstrucutred.studio", + "errors": { + "invalid": "Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed." + } + }, "bio": { "label": "Bio", "helpText": "Tell us something interesting about you! You can share what care about, your hobbies, things you have been working on recently, etc.", diff --git a/zubhub_frontend/zubhub/public/locales/hi/translation.json b/zubhub_frontend/zubhub/public/locales/hi/translation.json index d046ef792..961bd5a44 100644 --- a/zubhub_frontend/zubhub/public/locales/hi/translation.json +++ b/zubhub_frontend/zubhub/public/locales/hi/translation.json @@ -47,8 +47,15 @@ "email": { "label": "ईमेल", "errors": { - "invalid": "अवैध ईमेल", - "required": "यह फ़ील्ड आवश्यक है" + "phoneOrEmail": "आपको ईमेल या फोन नंबर देना होगा", + "invalid": "अवैध ईमेल" + } + }, + "phone": { + "label": "फ़ोन", + "errors": { + "phoneOrEmail": "आपको ईमेल या फोन नंबर देना होगा", + "invalid": "फ़ोन नंबर प्रारूप में दर्ज किया जाना चाहिए: '+999999999'। 15 अंकों तक की अनुमति।" } }, "dateOfBirth": { @@ -79,9 +86,16 @@ "required": "यह फ़ील्ड आवश्यक है" } }, + "bio": { + "label": "जैव", + "helpText": "हमें आपके बारे में कुछ रोचक बताएं! आप इस बात की परवाह कर सकते हैं कि आपके शौक, हाल ही में आपके द्वारा किए गए काम, आदि", + "errors": { + "tooLong": "आपका जैव 255 से अधिक वर्ण नहीं होना चाहिए" + } + }, "submit": "साइन अप करें" }, - + "unsubscribe": "मैं व्हाट्सएप / एसएमएस या ई-मेल के माध्यम से नई टिंकरिंग परियोजनाओं और विचारों पर अपडेट प्राप्त नहीं करना चाहता", "alreadyAMember": "पहले से सदस्य हैं ?", "login": "लॉग इन करें", "errors": { @@ -186,6 +200,19 @@ "unexpected": "यह क्रिया करते समय एक त्रुटि हुई। बाद में पुन: प्रयास करें" } }, + "phoneConfirm": { + "welcomeMsg": { + "primary": "फोन की पुष्टि", + "secondary": "कृपया पुष्टि करें कि आप <> हैं और आपके द्वारा अपने खाते से कनेक्ट किया गया फ़ोन नंबर आपसे संबंधित है:" + }, + "inputs": { + "submit": "पुष्टि करें" + }, + "toastSuccess": "बधाई !, आपके फ़ोन नंबर की पुष्टि हो गई है!", + "errors": { + "unexpected": "यह क्रिया करते समय एक त्रुटि हुई। बाद में पुन: प्रयास करें" + } + }, "projects": { "prev": "पिछला", @@ -382,6 +409,20 @@ "required": "यह फ़ील्ड आवश्यक है" } }, + "email": { + "label": "ईमेल", + "disabledHelperText": "इस फ़ील्ड मेल को संपादित करने के लिए admin@unstrucutred.studio", + "errors": { + "invalid": "अमान्य ईमेल" + } + }, + "phone": { + "label": "फ़ोन", + "disabledHelperText": "इस फ़ील्ड मेल को संपादित करने के लिए admin@unstrucutred.studio", + "errors": { + "invalid": "फ़ोन नंबर प्रारूप में दर्ज किया जाना चाहिए: '+999999999'। 15 अंकों तक की अनुमति।" + } + }, "bio": { "label": "जैव", "helpText": "हमें आपके बारे में कुछ रोचक बताएं! आप इस बात की परवाह कर सकते हैं कि आपके शौक, हाल ही में आपके द्वारा किए गए काम, आदि", diff --git a/zubhub_frontend/zubhub/src/App.js b/zubhub_frontend/zubhub/src/App.js index 57065f1e9..e38c5dea3 100644 --- a/zubhub_frontend/zubhub/src/App.js +++ b/zubhub_frontend/zubhub/src/App.js @@ -10,6 +10,7 @@ import Login from './views/login/Login'; import PasswordReset from './views/password_reset/PasswordReset'; import PasswordResetConfirm from './views/password_reset_confirm/PasswordResetConfirm'; import EmailConfirm from './views/email_confirm/EmailConfirm'; +import PhoneConfirm from './views/phone_confirm/PhoneConfirm'; import Profile from './views/profile/Profile'; import EditProfile from './views/edit_profile/EditProfile'; import UserProjects from './views/user_projects/UserProjects'; @@ -79,6 +80,15 @@ function App(props) { )} /> + ( + + + + )} + /> + ( diff --git a/zubhub_frontend/zubhub/src/api/api.js b/zubhub_frontend/zubhub/src/api/api.js index 5af60d08c..7b6b7cd2c 100644 --- a/zubhub_frontend/zubhub/src/api/api.js +++ b/zubhub_frontend/zubhub/src/api/api.js @@ -65,7 +65,6 @@ class API { const url = 'rest-auth/login/'; const method = 'POST'; const body = JSON.stringify({ username, password }); - console.log('stringified json', body); return this.request({ url, method, body }).then(res => res.json()); }; @@ -83,20 +82,26 @@ class API { signup = ({ username, email, + phone, dateOfBirth, user_location, password1, password2, + bio, + subscribe, }) => { - const url = 'rest-auth/registration/'; + const url = 'creators/register/'; const method = 'POST'; const body = JSON.stringify({ username, email, + phone, dateOfBirth, location: user_location, password1, password2, + bio, + subscribe, }); return this.request({ url, method, body }).then(res => res.json()); @@ -115,6 +120,18 @@ class API { }; /*******************************************************************/ + /*****************send phone confirmation ******************/ + send_phone_confirmation = key => { + const url = 'creators/verify-phone/'; + const method = 'POST'; + const body = JSON.stringify({ key }); + + return this.request({ url, method, body }).then(res => + Promise.resolve(res.status === 200 ? { detail: 'ok' } : res.json()), + ); + }; + /*******************************************************************/ + /********************send password reset link********************************/ send_password_reset_link = email => { const url = 'rest-auth/password/reset/'; @@ -204,12 +221,22 @@ class API { /************************** edit user profile **************************/ edit_user_profile = props => { - const { token, username, dateOfBirth, bio, user_location } = props; + const { + token, + username, + email, + phone, + dateOfBirth, + bio, + user_location, + } = props; const url = 'creators/edit_creator/'; const method = 'PUT'; const body = JSON.stringify({ username, + email, + phone, dateOfBirth, bio, location: user_location, diff --git a/zubhub_frontend/zubhub/src/store/actions/authActions.js b/zubhub_frontend/zubhub/src/store/actions/authActions.js index 3158dd21e..460a7a8b9 100644 --- a/zubhub_frontend/zubhub/src/store/actions/authActions.js +++ b/zubhub_frontend/zubhub/src/store/actions/authActions.js @@ -14,7 +14,6 @@ export const setAuthUser = auth_user => { export const login = args => { return dispatch => { - console.log(args); return API.login(args.values) .then(res => { if (!res.key) { @@ -97,6 +96,21 @@ export const send_email_confirmation = args => { }; }; +export const send_phone_confirmation = args => { + return () => { + return API.send_phone_confirmation(args.key).then(res => { + if (res.detail !== 'ok') { + throw new Error(res.detail); + } else { + toast.success(args.t('phoneConfirm.toastSuccess')); + setTimeout(() => { + args.history.push('/'); + }, 4000); + } + }); + }; +}; + export const send_password_reset_link = args => { return () => { return API.send_password_reset_link(args.email).then(res => { diff --git a/zubhub_frontend/zubhub/src/views/create_project/CreateProject.jsx b/zubhub_frontend/zubhub/src/views/create_project/CreateProject.jsx index 069fad071..4ffa35ae4 100644 --- a/zubhub_frontend/zubhub/src/views/create_project/CreateProject.jsx +++ b/zubhub_frontend/zubhub/src/views/create_project/CreateProject.jsx @@ -308,10 +308,9 @@ function CreateProject(props) { server_errors[key] = messages[key][0]; } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ ...server_errors }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('createProject.errors.unexpected'), }); } diff --git a/zubhub_frontend/zubhub/src/views/edit_profile/EditProfile.jsx b/zubhub_frontend/zubhub/src/views/edit_profile/EditProfile.jsx index fa4160c30..6b3a0a7e5 100644 --- a/zubhub_frontend/zubhub/src/views/edit_profile/EditProfile.jsx +++ b/zubhub_frontend/zubhub/src/views/edit_profile/EditProfile.jsx @@ -44,22 +44,36 @@ const get_locations = props => { const getProfile = (refs, props) => { return props.get_auth_user(props).then(obj => { if (!obj.id) { - return obj; + return; } else { - props.setFieldValue('username', obj.username); - if (refs.usernameEl.current) + let init_email_and_phone = {}; + if (refs.usernameEl.current && obj.username) { + props.setFieldValue('username', obj.username); refs.usernameEl.current.firstChild.value = obj.username; + } - props.setFieldValue('bio', obj.bio); - if (refs.bioEl.current) refs.bioEl.current.firstChild.value = obj.bio; + if (refs.emailEl.current && obj.email) { + props.setFieldValue('email', obj.email); + refs.emailEl.current.firstChild.value = obj.email; + init_email_and_phone['init_email'] = obj.email; //this is a hack: we need to find a better way of setting knowing when phone and email exists. state doesn't seem to work + } - if (obj.dateOfBirth) { - props.setFieldValue('dateOfBirth', obj.dateOfBirth); + if (refs.phoneEl.current && obj.phone) { + props.setFieldValue('phone', obj.phone); + refs.phoneEl.current.firstChild.value = obj.phone; + init_email_and_phone['init_phone'] = obj.phone; //this is a hack: we need to find a better way of setting knowing when phone and email exists. state doesn't seem to work } if (obj.location) { props.setFieldValue('user_location', obj.location); } + + if (refs.bioEl.current && obj.bio) { + props.setFieldValue('bio', obj.bio); + refs.bioEl.current.firstChild.value = obj.bio; + } + + props.setStatus(init_email_and_phone); //the hack continues } }); }; @@ -88,10 +102,14 @@ const editProfile = (e, props) => { server_errors[key] = messages[key][0]; } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ + //this is a hack. we need to find a more react way of maintaining initial state for email and phone. + init_email: props.status && props.status.init_email, + init_phone: props.status && props.status.init_phone, + ...server_errors, + }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('editProfile.errors.unexpected'), }); } @@ -106,14 +124,14 @@ const handleTooltipToggle = ({ toolTipOpen }) => { function EditProfile(props) { const refs = { usernameEl: React.useRef(null), - bioEl: React.useRef(null), - dobEl: React.useRef(null), locationEl: React.useRef(null), + emailEl: React.useRef(null), + phoneEl: React.useRef(null), + bioEl: React.useRef(null), }; const [state, setState] = React.useState({ locations: [], - current_location: '', toolTipOpen: false, }); @@ -191,7 +209,7 @@ function EditProfile(props) { {t('editProfile.inputs.username.label')} @@ -217,14 +235,10 @@ function EditProfile(props) { > @@ -265,7 +279,7 @@ function EditProfile(props) { {t('editProfile.inputs.location.label')} @@ -273,14 +287,10 @@ function EditProfile(props) { labelId="user_location" id="user_location" name="user_location" - className={ - props.values['user_location'] - ? clsx( - classes.customInputStyle, - classes.staticLabelInputStyle, - ) - : classes.customInputStyle - } + className={clsx( + classes.customInputStyle, + classes.staticLabelInputStyle, + )} value={ props.values.user_location ? props.values.user_location @@ -311,6 +321,122 @@ function EditProfile(props) { + + + + {t('editProfile.inputs.email.label')} + + + + {props.status && props.status['init_email'] && ( + + {t('editProfile.inputs.email.disabledHelperText')} + + )} +
+ {(props.status && props.status['email']) || + (props.touched['email'] && + props.errors['email'] && + t( + `editProfile.inputs.email.errors.${props.errors['email']}`, + ))} +
+
+
+ + + + + {t('editProfile.inputs.phone.label')} + + + + {props.status && props.status['init_phone'] && ( + + {t('editProfile.inputs.phone.disabledHelperText')} + + )} +
+ {(props.status && props.status['phone']) || + (props.touched['phone'] && + props.errors['phone'] && + t( + `editProfile.inputs.phone.errors.${props.errors['phone']}`, + ))} +
+
+
+ {t('editProfile.inputs.bio.label')} { - if (obj) { - Promise.resolve(obj).then(obj => { - setState({ ...state, ...obj }); - }); - } - }; - - const { error } = state; username = state.username; const { t } = props; @@ -80,7 +71,7 @@ function EmailConfirm(props) { className="auth-form" name="email_confirm" noValidate="noValidate" - onSubmit={e => handleSetState(confirmEmail(e, props, state))} + onSubmit={e => confirmEmail(e, props, state)} > { server_errors[key] = messages[key][0]; } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ ...server_errors }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('login.errors.unexpected'), }); } diff --git a/zubhub_frontend/zubhub/src/views/password_reset/PasswordReset.jsx b/zubhub_frontend/zubhub/src/views/password_reset/PasswordReset.jsx index 96b0621bc..bd4851369 100644 --- a/zubhub_frontend/zubhub/src/views/password_reset/PasswordReset.jsx +++ b/zubhub_frontend/zubhub/src/views/password_reset/PasswordReset.jsx @@ -47,10 +47,9 @@ const sendPasswordResetLink = (e, props) => { server_errors[key] = messages[key][0]; } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ ...server_errors }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('passwordResetConfirm.errors.unexpected'), }); } diff --git a/zubhub_frontend/zubhub/src/views/password_reset_confirm/PasswordResetConfirm.jsx b/zubhub_frontend/zubhub/src/views/password_reset_confirm/PasswordResetConfirm.jsx index e1b08aec2..d38f02e4b 100644 --- a/zubhub_frontend/zubhub/src/views/password_reset_confirm/PasswordResetConfirm.jsx +++ b/zubhub_frontend/zubhub/src/views/password_reset_confirm/PasswordResetConfirm.jsx @@ -62,10 +62,9 @@ const resetPassword = (e, props) => { } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ ...server_errors }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('passwordResetConfirm.errors.unexpected'), }); } diff --git a/zubhub_frontend/zubhub/src/views/phone_confirm/PhoneConfirm.jsx b/zubhub_frontend/zubhub/src/views/phone_confirm/PhoneConfirm.jsx new file mode 100644 index 000000000..4140c8d10 --- /dev/null +++ b/zubhub_frontend/zubhub/src/views/phone_confirm/PhoneConfirm.jsx @@ -0,0 +1,149 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { connect } from 'react-redux'; + +import 'react-toastify/dist/ReactToastify.css'; + +import { makeStyles } from '@material-ui/core/styles'; +import { + Grid, + Box, + Container, + Card, + CardActionArea, + CardContent, + Typography, +} from '@material-ui/core'; + +import * as AuthActions from '../../store/actions/authActions'; +import CustomButton from '../../components/button/Button'; +import styles from '../../assets/js/styles/views/email_confirm/emailConfirmStyles'; + +const useStyles = makeStyles(styles); + +const getUsernameAndKey = queryString => { + let username = queryString.split('&&'); + const key = username[1].split('=')[1]; + username = username[0].split('=')[1]; + return { username, key }; +}; + +const confirmPhone = (e, props, state) => { + e.preventDefault(); + return props + .send_phone_confirmation({ + key: state.key, + t: props.t, + history: props.history, + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + props.setStatus({ + non_field_errors: props.t('phoneConfirm.errors.unexpected'), + }); + } else { + props.setStatus({ non_field_errors: error.message }); + } + }); +}; + +function PhoneConfirm(props) { + const classes = useStyles(); + + let { username, key } = getUsernameAndKey(props.location.search); + + const [state] = React.useState({ + username: username ?? null, + key: key ?? null, + }); + + username = state.username; + const { t } = props; + + return ( + + + + + +
confirmPhone(e, props, state)} + > + + {t('phoneConfirm.welcomeMsg.primary')} + + + {t('phoneConfirm.welcomeMsg.secondary').replace( + '<>', + username, + )} + + + + + + {props.status && props.status['non_field_errors'] && ( + + {props.status['non_field_errors']} + + )} + + + + + {t('phoneConfirm.inputs.submit')} + + + +
+
+
+
+
+
+ ); +} + +PhoneConfirm.propTypes = { + auth: PropTypes.object.isRequired, + send_email_confirmation: PropTypes.func.isRequired, +}; + +const mapStateToProps = state => { + return { + auth: state.auth, + }; +}; + +const mapDispatchToProps = dispatch => { + return { + send_phone_confirmation: args => { + return dispatch(AuthActions.send_phone_confirmation(args)); + }, + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(PhoneConfirm); diff --git a/zubhub_frontend/zubhub/src/views/profile/Profile.jsx b/zubhub_frontend/zubhub/src/views/profile/Profile.jsx index 5b664553f..3f64054ae 100644 --- a/zubhub_frontend/zubhub/src/views/profile/Profile.jsx +++ b/zubhub_frontend/zubhub/src/views/profile/Profile.jsx @@ -285,9 +285,14 @@ function Profile(props) { {profile.username}
{props.auth.username === profile.username ? ( - - {profile.email} - + <> + + {profile.email} + + + {profile.phone} + + ) : null} diff --git a/zubhub_frontend/zubhub/src/views/signup/Signup.jsx b/zubhub_frontend/zubhub/src/views/signup/Signup.jsx index 191bd81cb..9ba9196bd 100644 --- a/zubhub_frontend/zubhub/src/views/signup/Signup.jsx +++ b/zubhub_frontend/zubhub/src/views/signup/Signup.jsx @@ -30,6 +30,8 @@ import { InputLabel, FormHelperText, FormControl, + FormControlLabel, + Checkbox, } from '@material-ui/core'; import CustomButton from '../../components/button/Button'; @@ -38,6 +40,9 @@ import styles from '../../assets/js/styles/views/signup/signupStyles'; const useStyles = makeStyles(styles); +let phone_field_touched; +let email_field_touched; + const handleMouseDownPassword = e => { e.preventDefault(); }; @@ -48,11 +53,22 @@ const get_locations = props => { const signup = (e, props) => { e.preventDefault(); - if (props.values.user_location.length < 1) { - props.validateField('user_location'); + if (!props.values.user_location || props.values.user_location.length < 1) { + props.setFieldTouched('username', true); + props.setFieldTouched('email', true); + props.setFieldTouched('phone', true); + props.setFieldTouched('dateOfBirth', true); + props.setFieldTouched('user_location', true); + props.setFieldTouched('password1', true); + props.setFieldTouched('password2', true); + phone_field_touched = true; + email_field_touched = true; } else { return props - .signup({ values: props.values, history: props.history }) + .signup({ + values: { ...props.values, subscribe: !props.values.subscribe }, + history: props.history, + }) .catch(error => { const messages = JSON.parse(error.message); if (typeof messages === 'object') { @@ -66,10 +82,9 @@ const signup = (e, props) => { server_errors[key] = messages[key][0]; } }); - props.setStatus({ ...props.status, ...server_errors }); + props.setStatus({ ...server_errors }); } else { props.setStatus({ - ...props.status, non_field_errors: props.t('signup.errors.unexpected'), }); } @@ -81,18 +96,39 @@ const handleTooltipToggle = ({ toolTipOpen }) => { return { toolTipOpen: !toolTipOpen }; }; +const handleToggleSubscribeBox = (e, props, state) => { + let subscribeBoxChecked = !state.subscribeBoxChecked; + props.setFieldValue('subscribe', subscribeBoxChecked); + return { subscribeBoxChecked }; +}; + function Signup(props) { const [state, setState] = React.useState({ locations: [], showPassword1: false, showPassword2: false, toolTipOpen: false, + subscribeBoxChecked: false, }); React.useEffect(() => { handleSetState(get_locations(props)); }, []); + React.useEffect(() => { + if (props.touched['email']) { + email_field_touched = true; + } else { + email_field_touched = false; + } + + if (props.touched['phone']) { + phone_field_touched = true; + } else { + phone_field_touched = false; + } + }, [props.touched['email'], props.touched['phone']]); + const classes = useStyles(); const handleSetState = obj => { @@ -103,7 +139,13 @@ function Signup(props) { } }; - const { locations, toolTipOpen, showPassword1, showPassword2 } = state; + const { + locations, + toolTipOpen, + showPassword1, + showPassword2, + subscribeBoxChecked, + } = state; const { t } = props; return ( @@ -204,7 +246,7 @@ function Signup(props) { (props.touched['username'] && props.errors['username'] && t( - `signup.inputs.username.errors.${this.props.errors['username']}`, + `signup.inputs.username.errors.${props.errors['username']}`, ))}
@@ -216,9 +258,6 @@ function Signup(props) { variant="outlined" size="small" fullWidth - InputLabelProps={{ - shrink: true, - }} margin="normal" error={ (props.status && props.status['email']) || @@ -251,6 +290,44 @@ function Signup(props) {
+ + + + {t('signup.inputs.phone.label')} + + + + {(props.status && props.status['phone']) || + (props.touched['phone'] && + props.errors['phone'] && + t( + `signup.inputs.phone.errors.${props.errors['phone']}`, + ))} + + + + } - labelWidth={70} + labelWidth={150} /> {(props.status && props.status['password2']) || @@ -468,6 +545,83 @@ function Signup(props) { + + + + + {t('signup.inputs.bio.label')} + + + + + {t('signup.inputs.bio.helpText')} + +
+ {(props.status && props.status['bio']) || + (props.touched['bio'] && + props.errors['bio'] && + t( + `signup.inputs.bio.errors.${props.errors['bio']}`, + ))} +
+
+
+ + + handleSetState( + handleToggleSubscribeBox(e, props, state), + ) + } + control={ + + } + label={ + + {t('signup.unsubscribe')} + + } + labelPlacement="end" + /> + + ({ username: '', email: '', + phone: '', user_location: '', password1: '', password2: '', }), validationSchema: Yup.object().shape({ username: Yup.string().required('required'), - email: Yup.string().email('invalid').required('required'), + email: Yup.string() + .email('invalid') + .test('email_is_empty', 'phoneOrEmail', function (value) { + return email_field_touched && !value && !this.parent.phone + ? false + : true; + }), + phone: Yup.string() + .test('phone_is_invalid', 'invalid', function (value) { + return /^[+][0-9]{9,15}$/g.test(value) || !value ? true : false; + }) + .test('phone_is_empty', 'phoneOrEmail', function (value) { + return phone_field_touched && !value && !this.parent.email + ? false + : true; + }), dateOfBirth: Yup.date().max(new Date(), 'max').required('required'), user_location: Yup.string().min(1, 'min').required('required'), password1: Yup.string().min(8, 'min').required('required'), @@ -565,5 +735,6 @@ export default connect( .oneOf([Yup.ref('password1'), null], 'noMatch') .required('required'), }), + bio: Yup.string().max(255, 'tooLong'), })(Signup), );