diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index 89e999a8..950d9050 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,174 +1,260 @@ all(), [ - 'email' => 'required|email', + 'email' => 'required', 'name' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } + // Validate user email (or phone) + if ($error = $this->validatePhoneOrEmail($request->email, $is_phone)) { + return response()->json(['status' => 'error', 'errors' => ['email' => $error]], 422); + } + // Generate the verification code $code = SignupCode::create([ 'data' => [ 'email' => $request->email, 'name' => $request->name, ] ]); - // TODO: send email/sms message + // Send email/sms message + $this->{$is_phone ? 'sendSMS' : 'sendEmail'}($code); return response()->json(['status' => 'success', 'code' => $code->code]); } /** * Validation of the verification code. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Validate the code + // Validate the verification code $code = SignupCode::find($request->code); if (empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two (::destroy()) $this->code = $code; // Return user name and email/phone from the codes database on success return response()->json([ 'status' => 'success', 'email' => $code->data['email'], 'name' => $code->data['name'], ]); } /** * Finishes the signup process by creating the user account. * * @param Illuminate\Http\Request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'domain' => 'required|min:3', 'login' => 'required|min:2', 'password' => 'required|min:3|confirmed', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $login = $request->login . '@' . $request->domain; - // TODO: check if specified domain is ours - // TODO: validate login + // Validate login (email) + if ($error = $this->validateEmail($login, true)) { + return response()->json(['status' => 'error', 'errors' => ['login' => $error]], 422); + } // Validate verification codes (again) $v = $this->verify($request); if ($v->status() !== 200) { return $v; } $code_data = $v->getData(); $user_name = $code_data->name; $user_email = $code_data->email; - // TODO: check if user with specified login already exists + // We allow only ASCII, so we can safely lower-case the email address + $login = Str::lower($login); $user = User::create( [ - // TODO: Save the external email (or phone) ? + // TODO: Save the external email (or phone) 'name' => $user_name, 'email' => $login, 'password' => $request->password, ] ); // Remove the verification code $this->code->delete(); $token = auth()->login($user); - return $this->respondWithToken($token); + return response()->json([ + 'access_token' => $token, + 'token_type' => 'bearer', + 'expires_in' => Auth::guard()->factory()->getTTL() * 60, + ]); } /** - * Get the token array structure. + * Checks if the input string is a valid email address or a phone number * - * @param string $token Respond with this token. + * @param string $email Email address or phone number + * @param bool &$is_phone Will be set to True if the string is valid phone number * - * @return \Illuminate\Http\JsonResponse JSON response + * @return string Error message on validation error */ - protected function respondWithToken($token) + protected function validatePhoneOrEmail($input, &$is_phone = false) { - return response()->json([ - 'access_token' => $token, - 'token_type' => 'bearer', - 'expires_in' => Auth::guard()->factory()->getTTL() * 60, - ]); + $is_phone = false; + + if (strpos($input, '@')) { + return $this->validateEmail($input); + } + + $input = str_replace(array('-', ' '), '', $input); + + if (!preg_match('/^\+?[0-9]{9,12}$/', $input)) { + return __('validation.noemailorphone'); + } + + $is_phone = true; + } + + /** + * Email address validation + * + * @param string $email Email address + * @param bool $signup Enables additional checks for signup mode + * + * @return string Error message on validation error + */ + protected function validateEmail($email, $signup = false) + { + $v = Validator::make(['email' => $email], ['email' => 'required|email']); + + if ($v->fails()) { + return __('validation.emailinvalid'); + } + + // Extended checks for an address that is supposed to become a login to Kolab + if ($signup) { + list($local, $domain) = explode('@', $email); + + // Local part validation + if (!preg_match('/^[A-Za-z0-9_.-]+$/', $local)) { + return __('validation.emailinvalid'); + } + + // Check if the local part is not one of exceptions + $exceptions = '/^(admin|administrator|sales|root)$/i'; + if (preg_match($exceptions, $local)) { + return __('validation.emailexists'); + } + + // Check if user with specified login already exists + if (User::where('email', $email)->first()) { + return __('validation.emailexists'); + } + + // TODO: check if specified domain is ours + } + } + + /** + * Sends SMS message for signup verification. + * + * @param App\SignupCode $code Verification code object + */ + protected function sendSMS(SignupCode $code) + { + // TODO + } + + /** + * Sends Email message for signup verification. + * + * @param App\SignupCode $code Verification code object + */ + protected function sendEmail(SignupCode $code) + { + // TODO } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index 5b505dc5..c47e60d7 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,82 +1,83 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') window.Vue = require('vue') import router from '../vue/js/routes.js' import AppComponent from '../vue/components/App' const app = new Vue({ el: '#app', components: { AppComponent }, router, methods: { clearFormValidation: form => { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() } }, mounted() { this.$root.$on('clearFormValidation', (form) => { this.clearFormValidation(form) }) } }) import VueToastr from '@deveodk/vue-toastr' // You need a specific loader for CSS files like https://github.com/webpack/css-loader // If you would like custom styling of the toastr the css file can be replaced import '@deveodk/vue-toastr/dist/@deveodk/vue-toastr.css' Vue.use(VueToastr, { defaultPosition: 'toast-bottom-right', defaultTimeout: 50000 }) // Add a response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { var error_msg if (error.response && error.response.status == 422) { error_msg = "Form validation error" $.each(error.response.data.errors || {}, (idx, msg) => { $('form').each((i, form) => { const input_name = ($(form).data('validation-prefix') || '') + idx const input = $('#' + input_name) if (input.length) { input.addClass('is-invalid') - .parent().append($('
').text(msg.join('
'))) + .parent().append($('
') + .text($.type(msg) === 'string' ? msg : msg.join('
'))) return false } }); }) $('form .is-invalid').first().focus() } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toastr('error', error_msg || "Server Error", 'Error') // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index e1d879f3..57695f52 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,150 +1,154 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', + 'emailexists' => 'The specified email address already exists', + 'emailinvalid' => 'The specified email address is invalid', + 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number', + /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ];