diff --git a/.gitignore b/.gitignore index 96f2208b4..d8e8b6445 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,6 @@ config.php *.zip -vendor/* - composer.lock .env composer.local.* @@ -26,18 +24,26 @@ local/module_configs/*.yml local/modules/* local/views/widgets/* -.php_cs.cache + composer storage/framework/down -node_modules -.phpunit.result.cache + +# File uploads +public/storage + storage/oauth-private.key storage/oauth-public.key storage/*.index storage/public/app/* +# Outputs of testing/linting tools +.php_cs.cache +.phpunit.result.cache +# Do not commit dependencies +node_modules +vendor/* # Laravel mix / Webpack output public/js/*.js @@ -55,3 +61,13 @@ public/hot programmatic-types.graphql schema-directives.graphql _lighthouse_ide_helper.php + +# JS/CSS published from Laravel Packages +public/vendor + +# Auto-generated Ziggy routes should be packaged at build time +resources/js/ziggy.d.ts +resources/js/ziggy.js + +# Auto-generated GraphQL types should be packaged at build time +resources/js/gql diff --git a/public/assets/css/bootstrap4-tagsinput.css b/public/assets/css/bootstrap4-tagsinput.css new file mode 100644 index 000000000..c2e6e3526 --- /dev/null +++ b/public/assets/css/bootstrap4-tagsinput.css @@ -0,0 +1,103 @@ +/* + * bootstrap-tagsinput v0.8.0 + * + */ + +.bootstrap-tagsinput { + background-color: #fff; + border: 1px solid #ccc; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + display: inline-block; + padding: 4px 6px; + color: #555; + vertical-align: middle; + border-radius: 4px; + width: 100%; + line-height: 22px; + cursor: text; +} +.bootstrap-tagsinput input { + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + padding: 0 6px; + margin: 0; + width: auto; + max-width: inherit; +} +.bootstrap-tagsinput.form-control input::-moz-placeholder { + color: #777; + opacity: 1; +} +.bootstrap-tagsinput.form-control input:-ms-input-placeholder { + color: #777; +} +.bootstrap-tagsinput.form-control input::-webkit-input-placeholder { + color: #777; +} +.bootstrap-tagsinput input:focus { + border: none; + box-shadow: none; +} +.bootstrap-tagsinput .badge { + margin: 2px 0; + padding:5px 8px; +} +.bootstrap-tagsinput .badge [data-role="remove"] { + margin-left: 8px; + cursor: pointer; +} +.bootstrap-tagsinput .badge [data-role="remove"]:after { + content: "×"; + padding: 0px 4px; + background-color:rgba(0, 0, 0, 0.1); + border-radius:50%; + font-size:13px +} +.bootstrap-tagsinput .badge [data-role="remove"]:hover:after { + + background-color:rgba(0, 0, 0, 0.62);} +.bootstrap-tagsinput .badge [data-role="remove"]:hover:active { + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} + +.tt-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + list-style: none; + font-size: 14px; + background-color: #ffffff; + border: 1px solid #cccccc; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); + background-clip: padding-box; + cursor: pointer; +} + +.tt-suggestion { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.428571429; + color: #333333; + white-space: nowrap; +} + +.tt-suggestion:hover, +.tt-suggestion:focus { + color: #ffffff; + text-decoration: none; + outline: 0; + background-color: #428bca; +} diff --git a/public/assets/js/bootstrap4-tagsinput.js b/public/assets/js/bootstrap4-tagsinput.js new file mode 100644 index 000000000..032773c35 --- /dev/null +++ b/public/assets/js/bootstrap4-tagsinput.js @@ -0,0 +1,691 @@ +/* + * bootstrap-tagsinput v0.8.0 + * + */ + +(function ($) { + "use strict"; + + var defaultOptions = { + tagClass: function(item) { + return 'badge badge-info'; + }, + focusClass: 'focus', + itemValue: function(item) { + return item ? item.toString() : item; + }, + itemText: function(item) { + return this.itemValue(item); + }, + itemTitle: function(item) { + return null; + }, + freeInput: true, + addOnBlur: true, + maxTags: undefined, + maxChars: undefined, + confirmKeys: [13, 44], + delimiter: ',', + delimiterRegex: null, + cancelConfirmKeysOnEmpty: false, + onTagExists: function(item, $tag) { + $tag.hide().fadeIn(); + }, + trimValue: false, + allowDuplicates: false, + triggerChange: true, + editOnBackspace: false + }; + + /** + * Constructor function + */ + function TagsInput(element, options) { + this.isInit = true; + this.itemsArray = []; + + this.$element = $(element); + this.$element.addClass('sr-only'); + + this.isSelect = (element.tagName === 'SELECT'); + this.multiple = (this.isSelect && element.hasAttribute('multiple')); + this.objectItems = options && options.itemValue; + this.placeholderText = element.hasAttribute('placeholder') ? this.$element.attr('placeholder') : ''; + this.inputSize = Math.max(1, this.placeholderText.length); + + this.$container = $('
'); + this.$input = $('').appendTo(this.$container); + + this.$element.before(this.$container); + + this.build(options); + this.isInit = false; + } + + TagsInput.prototype = { + constructor: TagsInput, + + /** + * Adds the given item as a new tag. Pass true to dontPushVal to prevent + * updating the elements val() + */ + add: function(item, dontPushVal, options) { + var self = this; + + if (self.options.maxTags && self.itemsArray.length >= self.options.maxTags) + return; + + // Ignore falsey values, except false + if (item !== false && !item) + return; + + // Trim value + if (typeof item === "string" && self.options.trimValue) { + item = $.trim(item); + } + + // Throw an error when trying to add an object while the itemValue option was not set + if (typeof item === "object" && !self.objectItems) + throw("Can't add objects when itemValue option is not set"); + + // Ignore strings only containg whitespace + if (item.toString().match(/^\s*$/)) + return; + + // If SELECT but not multiple, remove current tag + if (self.isSelect && !self.multiple && self.itemsArray.length > 0) + self.remove(self.itemsArray[0]); + + if (typeof item === "string" && this.$element[0].tagName === 'INPUT') { + var delimiter = (self.options.delimiterRegex) ? self.options.delimiterRegex : self.options.delimiter; + var items = item.split(delimiter); + if (items.length > 1) { + for (var i = 0; i < items.length; i++) { + this.add(items[i], true); + } + + if (!dontPushVal) + self.pushVal(self.options.triggerChange); + return; + } + } + + var itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item), + itemTitle = self.options.itemTitle(item); + + // Ignore items allready added + var existing = $.grep(self.itemsArray, function(item) { return self.options.itemValue(item) === itemValue; } )[0]; + if (existing && !self.options.allowDuplicates) { + // Invoke onTagExists + if (self.options.onTagExists) { + var $existingTag = $(".badge", self.$container).filter(function() { return $(this).data("item") === existing; }); + self.options.onTagExists(item, $existingTag); + } + return; + } + + // if length greater than limit + if (self.items().toString().length + item.length + 1 > self.options.maxInputLength) + return; + + // raise beforeItemAdd arg + var beforeItemAddEvent = $.Event('beforeItemAdd', { item: item, cancel: false, options: options}); + self.$element.trigger(beforeItemAddEvent); + if (beforeItemAddEvent.cancel) + return; + + // register item in internal array and map + self.itemsArray.push(item); + + // add a tag element + + var $tag = $('' + htmlEncode(itemText) + ''); + $tag.data('item', item); + self.findInputWrapper().before($tag); + + // Check to see if the tag exists in its raw or uri-encoded form + var optionExists = ( + $('option[value="' + encodeURIComponent(itemValue).replace(/"/g, '\\"') + '"]', self.$element).length || + $('option[value="' + htmlEncode(itemValue).replace(/"/g, '\\"') + '"]', self.$element).length + ); + + // add if item represents a value not present in one of the 's options + if (self.isSelect && !optionExists) { + var $option = $(''); + $option.data('item', item); + $option.attr('value', itemValue); + self.$element.append($option); + } + + if (!dontPushVal) + self.pushVal(self.options.triggerChange); + + // Add class when reached maxTags + if (self.options.maxTags === self.itemsArray.length || self.items().toString().length === self.options.maxInputLength) + self.$container.addClass('bootstrap-tagsinput-max'); + + // If using typeahead, once the tag has been added, clear the typeahead value so it does not stick around in the input. + if ($('.typeahead, .twitter-typeahead', self.$container).length) { + self.$input.typeahead('val', ''); + } + + if (this.isInit) { + self.$element.trigger($.Event('itemAddedOnInit', { item: item, options: options })); + } else { + self.$element.trigger($.Event('itemAdded', { item: item, options: options })); + } + }, + + /** + * Removes the given item. Pass true to dontPushVal to prevent updating the + * elements val() + */ + remove: function(item, dontPushVal, options) { + var self = this; + + if (self.objectItems) { + if (typeof item === "object") + item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == self.options.itemValue(item); } ); + else + item = $.grep(self.itemsArray, function(other) { return self.options.itemValue(other) == item; } ); + + item = item[item.length-1]; + } + + if (item) { + var beforeItemRemoveEvent = $.Event('beforeItemRemove', { item: item, cancel: false, options: options }); + self.$element.trigger(beforeItemRemoveEvent); + if (beforeItemRemoveEvent.cancel) + return; + + $('.badge', self.$container).filter(function() { return $(this).data('item') === item; }).remove(); + $('option', self.$element).filter(function() { return $(this).data('item') === item; }).remove(); + if($.inArray(item, self.itemsArray) !== -1) + self.itemsArray.splice($.inArray(item, self.itemsArray), 1); + } + + if (!dontPushVal) + self.pushVal(self.options.triggerChange); + + // Remove class when reached maxTags + if (self.options.maxTags > self.itemsArray.length) + self.$container.removeClass('bootstrap-tagsinput-max'); + + self.$element.trigger($.Event('itemRemoved', { item: item, options: options })); + }, + + /** + * Removes all items + */ + removeAll: function() { + var self = this; + + $('.badge', self.$container).remove(); + $('option', self.$element).remove(); + + while(self.itemsArray.length > 0) + self.itemsArray.pop(); + + self.pushVal(self.options.triggerChange); + }, + + /** + * Refreshes the tags so they match the text/value of their corresponding + * item. + */ + refresh: function() { + var self = this; + $('.badge', self.$container).each(function() { + var $tag = $(this), + item = $tag.data('item'), + itemValue = self.options.itemValue(item), + itemText = self.options.itemText(item), + tagClass = self.options.tagClass(item); + + // Update tag's class and inner text + $tag.attr('class', null); + $tag.addClass('badge ' + htmlEncode(tagClass)); + $tag.contents().filter(function() { + return this.nodeType == 3; + })[0].nodeValue = htmlEncode(itemText); + + if (self.isSelect) { + var option = $('option', self.$element).filter(function() { return $(this).data('item') === item; }); + option.attr('value', itemValue); + } + }); + }, + + /** + * Returns the items added as tags + */ + items: function() { + return this.itemsArray; + }, + + /** + * Assembly value by retrieving the value of each item, and set it on the + * element. + */ + pushVal: function() { + var self = this, + val = $.map(self.items(), function(item) { + return self.options.itemValue(item).toString(); + }); + + self.$element.val( val.join(self.options.delimiter) ); + + if (self.options.triggerChange) + self.$element.trigger('change'); + }, + + /** + * Initializes the tags input behaviour on the element + */ + build: function(options) { + var self = this; + + self.options = $.extend({}, defaultOptions, options); + // When itemValue is set, freeInput should always be false + if (self.objectItems) + self.options.freeInput = false; + + makeOptionItemFunction(self.options, 'itemValue'); + makeOptionItemFunction(self.options, 'itemText'); + makeOptionFunction(self.options, 'tagClass'); + + // Typeahead Bootstrap version 2.3.2 + if (self.options.typeahead) { + var typeahead = self.options.typeahead || {}; + + makeOptionFunction(typeahead, 'source'); + + self.$input.typeahead($.extend({}, typeahead, { + source: function (query, process) { + function processItems(items) { + var texts = []; + + for (var i = 0; i < items.length; i++) { + var text = self.options.itemText(items[i]); + map[text] = items[i]; + texts.push(text); + } + process(texts); + } + + this.map = {}; + var map = this.map, + data = typeahead.source(query); + + if ($.isFunction(data.success)) { + // support for Angular callbacks + data.success(processItems); + } else if ($.isFunction(data.then)) { + // support for Angular promises + data.then(processItems); + } else { + // support for functions and jquery promises + $.when(data) + .then(processItems); + } + }, + updater: function (text) { + self.add(this.map[text]); + return this.map[text]; + }, + matcher: function (text) { + return (text.toLowerCase().indexOf(this.query.trim().toLowerCase()) !== -1); + }, + sorter: function (texts) { + return texts.sort(); + }, + highlighter: function (text) { + var regex = new RegExp( '(' + this.query + ')', 'gi' ); + return text.replace( regex, "$1" ); + } + })); + } + + // typeahead.js + if (self.options.typeaheadjs) { + // Determine if main configurations were passed or simply a dataset + var typeaheadjs = self.options.typeaheadjs; + if (!$.isArray(typeaheadjs)) { + typeaheadjs = [null, typeaheadjs]; + } + + $.fn.typeahead.apply(self.$input, typeaheadjs).on('typeahead:selected', $.proxy(function (obj, datum, name) { + var index = 0; + typeaheadjs.some(function(dataset, _index) { + if (dataset.name === name) { + index = _index; + return true; + } + return false; + }); + + // @TODO Dep: https://github.com/corejavascript/typeahead.js/issues/89 + if (typeaheadjs[index].valueKey) { + self.add(datum[typeaheadjs[index].valueKey]); + } else { + self.add(datum); + } + + self.$input.typeahead('val', ''); + }, self)); + } + + self.$container.on('click', $.proxy(function(event) { + if (! self.$element.attr('disabled')) { + self.$input.removeAttr('disabled'); + } + self.$input.focus(); + }, self)); + + if (self.options.addOnBlur && self.options.freeInput) { + self.$input.on('focusout', $.proxy(function(event) { + // HACK: only process on focusout when no typeahead opened, to + // avoid adding the typeahead text as tag + if ($('.typeahead, .twitter-typeahead', self.$container).length === 0) { + self.add(self.$input.val()); + self.$input.val(''); + } + }, self)); + } + + // Toggle the 'focus' css class on the container when it has focus + self.$container.on({ + focusin: function() { + self.$container.addClass(self.options.focusClass); + }, + focusout: function() { + self.$container.removeClass(self.options.focusClass); + }, + }); + + self.$container.on('keydown', 'input', $.proxy(function(event) { + var $input = $(event.target), + $inputWrapper = self.findInputWrapper(); + + if (self.$element.attr('disabled')) { + self.$input.attr('disabled', 'disabled'); + return; + } + + switch (event.which) { + // BACKSPACE + case 8: + if (doGetCaretPosition($input[0]) === 0) { + var prev = $inputWrapper.prev(); + if (prev.length) { + if (self.options.editOnBackspace === true) { + $input.val(prev.data('item')); + } + self.remove(prev.data('item')); + } + } + break; + + // DELETE + case 46: + if (doGetCaretPosition($input[0]) === 0) { + var next = $inputWrapper.next(); + if (next.length) { + self.remove(next.data('item')); + } + } + break; + + // LEFT ARROW + case 37: + // Try to move the input before the previous tag + var $prevTag = $inputWrapper.prev(); + if ($input.val().length === 0 && $prevTag[0]) { + $prevTag.before($inputWrapper); + $input.focus(); + } + break; + // RIGHT ARROW + case 39: + // Try to move the input after the next tag + var $nextTag = $inputWrapper.next(); + if ($input.val().length === 0 && $nextTag[0]) { + $nextTag.after($inputWrapper); + $input.focus(); + } + break; + default: + // ignore + } + + // Reset internal input's size + var textLength = $input.val().length, + wordSpace = Math.ceil(textLength / 5), + size = textLength + wordSpace + 1; + $input.attr('size', Math.max(this.inputSize, size)); + }, self)); + + self.$container.on('keypress', 'input', $.proxy(function(event) { + var $input = $(event.target); + + if (self.$element.attr('disabled')) { + self.$input.attr('disabled', 'disabled'); + return; + } + + var text = $input.val(), + maxLengthReached = self.options.maxChars && text.length >= self.options.maxChars; + if (self.options.freeInput && (keyCombinationInList(event, self.options.confirmKeys) || maxLengthReached)) { + // Only attempt to add a tag if there is data in the field + if (text.length !== 0) { + self.add(maxLengthReached ? text.substr(0, self.options.maxChars) : text); + $input.val(''); + } + + // If the field is empty, let the event triggered fire as usual + if (self.options.cancelConfirmKeysOnEmpty === false) { + event.preventDefault(); + } + } + + // Reset internal input's size + var textLength = $input.val().length, + wordSpace = Math.ceil(textLength / 5), + size = textLength + wordSpace + 1; + $input.attr('size', Math.max(this.inputSize, size)); + }, self)); + + // Remove icon clicked + self.$container.on('click', '[data-role=remove]', $.proxy(function(event) { + if (self.$element.attr('disabled')) { + return; + } + self.remove($(event.target).closest('.badge').data('item')); + }, self)); + + // Only add existing value as tags when using strings as tags + if (self.options.itemValue === defaultOptions.itemValue) { + if (self.$element[0].tagName === 'INPUT') { + self.add(self.$element.val()); + } else { + $('option', self.$element).each(function() { + self.add($(this).attr('value'), true); + }); + } + } + }, + + /** + * Removes all tagsinput behaviour and unregsiter all event handlers + */ + destroy: function() { + var self = this; + + // Unbind events + self.$container.off('keypress', 'input'); + self.$container.off('click', '[role=remove]'); + + self.$container.remove(); + self.$element.removeData('tagsinput'); + self.$element.show(); + }, + + /** + * Sets focus on the tagsinput + */ + focus: function() { + this.$input.focus(); + }, + + /** + * Returns the internal input element + */ + input: function() { + return this.$input; + }, + + /** + * Returns the element which is wrapped around the internal input. This + * is normally the $container, but typeahead.js moves the $input element. + */ + findInputWrapper: function() { + var elt = this.$input[0], + container = this.$container[0]; + while(elt && elt.parentNode !== container) + elt = elt.parentNode; + + return $(elt); + } + }; + + /** + * Register JQuery plugin + */ + $.fn.tagsinput = function(arg1, arg2, arg3) { + var results = []; + + this.each(function() { + var tagsinput = $(this).data('tagsinput'); + // Initialize a new tags input + if (!tagsinput) { + tagsinput = new TagsInput(this, arg1); + $(this).data('tagsinput', tagsinput); + results.push(tagsinput); + + if (this.tagName === 'SELECT') { + $('option', $(this)).attr('selected', 'selected'); + } + + // Init tags from $(this).val() + $(this).val($(this).val()); + } else if (!arg1 && !arg2) { + // tagsinput already exists + // no function, trying to init + results.push(tagsinput); + } else if(tagsinput[arg1] !== undefined) { + // Invoke function on existing tags input + if(tagsinput[arg1].length === 3 && arg3 !== undefined){ + var retVal = tagsinput[arg1](arg2, null, arg3); + }else{ + var retVal = tagsinput[arg1](arg2); + } + if (retVal !== undefined) + results.push(retVal); + } + }); + + if ( typeof arg1 == 'string') { + // Return the results from the invoked function calls + return results.length > 1 ? results : results[0]; + } else { + return results; + } + }; + + $.fn.tagsinput.Constructor = TagsInput; + + /** + * Most options support both a string or number as well as a function as + * option value. This function makes sure that the option with the given + * key in the given options is wrapped in a function + */ + function makeOptionItemFunction(options, key) { + if (typeof options[key] !== 'function') { + var propertyName = options[key]; + options[key] = function(item) { return item[propertyName]; }; + } + } + function makeOptionFunction(options, key) { + if (typeof options[key] !== 'function') { + var value = options[key]; + options[key] = function() { return value; }; + } + } + /** + * HtmlEncodes the given value + */ + var htmlEncodeContainer = $(''); + function htmlEncode(value) { + if (value) { + return htmlEncodeContainer.text(value).html(); + } else { + return ''; + } + } + + /** + * Returns the position of the caret in the given input field + * http://flightschool.acylt.com/devnotes/caret-position-woes/ + */ + function doGetCaretPosition(oField) { + var iCaretPos = 0; + if (document.selection) { + oField.focus (); + var oSel = document.selection.createRange(); + oSel.moveStart ('character', -oField.value.length); + iCaretPos = oSel.text.length; + } else if (oField.selectionStart || oField.selectionStart == '0') { + iCaretPos = oField.selectionStart; + } + return (iCaretPos); + } + + /** + * Returns boolean indicates whether user has pressed an expected key combination. + * @param object keyPressEvent: JavaScript event object, refer + * http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html + * @param object lookupList: expected key combinations, as in: + * [13, {which: 188, shiftKey: true}] + */ + function keyCombinationInList(keyPressEvent, lookupList) { + var found = false; + $.each(lookupList, function (index, keyCombination) { + if (typeof (keyCombination) === 'number' && keyPressEvent.which === keyCombination) { + found = true; + return false; + } + + if (keyPressEvent.which === keyCombination.which) { + var alt = !keyCombination.hasOwnProperty('altKey') || keyPressEvent.altKey === keyCombination.altKey, + shift = !keyCombination.hasOwnProperty('shiftKey') || keyPressEvent.shiftKey === keyCombination.shiftKey, + ctrl = !keyCombination.hasOwnProperty('ctrlKey') || keyPressEvent.ctrlKey === keyCombination.ctrlKey; + if (alt && shift && ctrl) { + found = true; + return false; + } + } + }); + + return found; + } + + /** + * Initialize tagsinput behaviour on inputs and selects which have + * data-role=tagsinput + */ + $(function() { + $("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput(); + }); +})(window.jQuery); diff --git a/routes/web-jetstream.php b/routes/web-jetstream.php new file mode 100644 index 000000000..b6fade620 --- /dev/null +++ b/routes/web-jetstream.php @@ -0,0 +1,35 @@ + Route::has('login'), +// 'canRegister' => Route::has('register'), +// 'laravelVersion' => Application::VERSION, +// 'phpVersion' => PHP_VERSION, +// ]); +//}); +// +//Route::middleware([ +// 'auth:sanctum', +// config('jetstream.auth_session'), +// 'verified', +//])->group(function () { +// Route::get('/dashboard', function () { +// return Inertia::render('Dashboard'); +// })->name('dashboard'); +//}); diff --git a/tests/Feature/ApiTokenPermissionsTest.php b/tests/Feature/ApiTokenPermissionsTest.php new file mode 100644 index 000000000..bdf73180b --- /dev/null +++ b/tests/Feature/ApiTokenPermissionsTest.php @@ -0,0 +1,43 @@ +markTestSkipped('API support is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->withPersonalTeam()->create()); + + $token = $user->tokens()->create([ + 'name' => 'Test Token', + 'token' => Str::random(40), + 'abilities' => ['create', 'read'], + ]); + + $response = $this->put('/user/api-tokens/'.$token->id, [ + 'name' => $token->name, + 'permissions' => [ + 'delete', + 'missing-permission', + ], + ]); + + $this->assertTrue($user->fresh()->tokens->first()->can('delete')); + $this->assertFalse($user->fresh()->tokens->first()->can('read')); + $this->assertFalse($user->fresh()->tokens->first()->can('missing-permission')); + } +} diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 000000000..7623fe9c2 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,45 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } +} diff --git a/tests/Feature/BrowserSessionsTest.php b/tests/Feature/BrowserSessionsTest.php new file mode 100644 index 000000000..46998c45a --- /dev/null +++ b/tests/Feature/BrowserSessionsTest.php @@ -0,0 +1,23 @@ +actingAs($user = User::factory()->create()); + + $response = $this->delete('/user/other-browser-sessions', [ + 'password' => 'password', + ]); + + $response->assertSessionHasNoErrors(); + } +} diff --git a/tests/Feature/CreateApiTokenTest.php b/tests/Feature/CreateApiTokenTest.php new file mode 100644 index 000000000..3220f50b4 --- /dev/null +++ b/tests/Feature/CreateApiTokenTest.php @@ -0,0 +1,37 @@ +markTestSkipped('API support is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->withPersonalTeam()->create()); + + $response = $this->post('/user/api-tokens', [ + 'name' => 'Test Token', + 'permissions' => [ + 'read', + 'update', + ], + ]); + + $this->assertCount(1, $user->fresh()->tokens); + $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name); + $this->assertTrue($user->fresh()->tokens->first()->can('read')); + $this->assertFalse($user->fresh()->tokens->first()->can('delete')); + } +} diff --git a/tests/Feature/DeleteAccountTest.php b/tests/Feature/DeleteAccountTest.php new file mode 100644 index 000000000..b50a2172a --- /dev/null +++ b/tests/Feature/DeleteAccountTest.php @@ -0,0 +1,47 @@ +markTestSkipped('Account deletion is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->create()); + + $response = $this->delete('/user', [ + 'password' => 'password', + ]); + + $this->assertNull($user->fresh()); + } + + public function test_correct_password_must_be_provided_before_account_can_be_deleted(): void + { + if (! Features::hasAccountDeletionFeatures()) { + $this->markTestSkipped('Account deletion is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->create()); + + $response = $this->delete('/user', [ + 'password' => 'wrong-password', + ]); + + $this->assertNotNull($user->fresh()); + } +} diff --git a/tests/Feature/DeleteApiTokenTest.php b/tests/Feature/DeleteApiTokenTest.php new file mode 100644 index 000000000..7d84a1a61 --- /dev/null +++ b/tests/Feature/DeleteApiTokenTest.php @@ -0,0 +1,35 @@ +markTestSkipped('API support is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->withPersonalTeam()->create()); + + $token = $user->tokens()->create([ + 'name' => 'Test Token', + 'token' => Str::random(40), + 'abilities' => ['create', 'read'], + ]); + + $response = $this->delete('/user/api-tokens/'.$token->id); + + $this->assertCount(0, $user->fresh()->tokens); + } +} diff --git a/tests/Feature/EmailVerificationTest.php b/tests/Feature/EmailVerificationTest.php new file mode 100644 index 000000000..ad0257d12 --- /dev/null +++ b/tests/Feature/EmailVerificationTest.php @@ -0,0 +1,79 @@ +markTestSkipped('Email verification not enabled.'); + + return; + } + + $user = User::factory()->withPersonalTeam()->unverified()->create(); + + $response = $this->actingAs($user)->get('/email/verify'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + if (! Features::enabled(Features::emailVerification())) { + $this->markTestSkipped('Email verification not enabled.'); + + return; + } + + Event::fake(); + + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(RouteServiceProvider::HOME.'?verified=1'); + } + + public function test_email_can_not_verified_with_invalid_hash(): void + { + if (! Features::enabled(Features::emailVerification())) { + $this->markTestSkipped('Email verification not enabled.'); + + return; + } + + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/tests/Feature/PasswordConfirmationTest.php b/tests/Feature/PasswordConfirmationTest.php new file mode 100644 index 000000000..7575d3aba --- /dev/null +++ b/tests/Feature/PasswordConfirmationTest.php @@ -0,0 +1,45 @@ +withPersonalTeam()->create(); + + $response = $this->actingAs($user)->get('/user/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/user/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/user/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/tests/Feature/PasswordResetTest.php b/tests/Feature/PasswordResetTest.php new file mode 100644 index 000000000..7c65edb99 --- /dev/null +++ b/tests/Feature/PasswordResetTest.php @@ -0,0 +1,102 @@ +markTestSkipped('Password updates are not enabled.'); + + return; + } + + $response = $this->get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + if (! Features::enabled(Features::resetPasswords())) { + $this->markTestSkipped('Password updates are not enabled.'); + + return; + } + + Notification::fake(); + + $user = User::factory()->create(); + + $response = $this->post('/forgot-password', [ + 'email' => $user->email, + ]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + if (! Features::enabled(Features::resetPasswords())) { + $this->markTestSkipped('Password updates are not enabled.'); + + return; + } + + Notification::fake(); + + $user = User::factory()->create(); + + $response = $this->post('/forgot-password', [ + 'email' => $user->email, + ]); + + Notification::assertSentTo($user, ResetPassword::class, function (object $notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + if (! Features::enabled(Features::resetPasswords())) { + $this->markTestSkipped('Password updates are not enabled.'); + + return; + } + + Notification::fake(); + + $user = User::factory()->create(); + + $response = $this->post('/forgot-password', [ + 'email' => $user->email, + ]); + + Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response->assertSessionHasNoErrors(); + + return true; + }); + } +} diff --git a/tests/Feature/ProfileInformationTest.php b/tests/Feature/ProfileInformationTest.php new file mode 100644 index 000000000..782b1fefb --- /dev/null +++ b/tests/Feature/ProfileInformationTest.php @@ -0,0 +1,25 @@ +actingAs($user = User::factory()->create()); + + $response = $this->put('/user/profile-information', [ + 'name' => 'Test Name', + 'email' => 'test@example.com', + ]); + + $this->assertEquals('Test Name', $user->fresh()->name); + $this->assertEquals('test@example.com', $user->fresh()->email); + } +} diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php new file mode 100644 index 000000000..90b0245e7 --- /dev/null +++ b/tests/Feature/RegistrationTest.php @@ -0,0 +1,60 @@ +markTestSkipped('Registration support is not enabled.'); + + return; + } + + $response = $this->get('/register'); + + $response->assertStatus(200); + } + + public function test_registration_screen_cannot_be_rendered_if_support_is_disabled(): void + { + if (Features::enabled(Features::registration())) { + $this->markTestSkipped('Registration support is enabled.'); + + return; + } + + $response = $this->get('/register'); + + $response->assertStatus(404); + } + + public function test_new_users_can_register(): void + { + if (! Features::enabled(Features::registration())) { + $this->markTestSkipped('Registration support is not enabled.'); + + return; + } + + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + } +} diff --git a/tests/Feature/TwoFactorAuthenticationSettingsTest.php b/tests/Feature/TwoFactorAuthenticationSettingsTest.php new file mode 100644 index 000000000..5ba5f622b --- /dev/null +++ b/tests/Feature/TwoFactorAuthenticationSettingsTest.php @@ -0,0 +1,75 @@ +markTestSkipped('Two factor authentication is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->create()); + + $this->withSession(['auth.password_confirmed_at' => time()]); + + $response = $this->post('/user/two-factor-authentication'); + + $this->assertNotNull($user->fresh()->two_factor_secret); + $this->assertCount(8, $user->fresh()->recoveryCodes()); + } + + public function test_recovery_codes_can_be_regenerated(): void + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->create()); + + $this->withSession(['auth.password_confirmed_at' => time()]); + + $this->post('/user/two-factor-authentication'); + $this->post('/user/two-factor-recovery-codes'); + + $user = $user->fresh(); + + $this->post('/user/two-factor-recovery-codes'); + + $this->assertCount(8, $user->recoveryCodes()); + $this->assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes())); + } + + public function test_two_factor_authentication_can_be_disabled(): void + { + if (! Features::canManageTwoFactorAuthentication()) { + $this->markTestSkipped('Two factor authentication is not enabled.'); + + return; + } + + $this->actingAs($user = User::factory()->create()); + + $this->withSession(['auth.password_confirmed_at' => time()]); + + $this->post('/user/two-factor-authentication'); + + $this->assertNotNull($user->fresh()->two_factor_secret); + + $this->delete('/user/two-factor-authentication'); + + $this->assertNull($user->fresh()->two_factor_secret); + } +} diff --git a/tests/Feature/UpdatePasswordTest.php b/tests/Feature/UpdatePasswordTest.php new file mode 100644 index 000000000..88de46056 --- /dev/null +++ b/tests/Feature/UpdatePasswordTest.php @@ -0,0 +1,56 @@ +actingAs($user = User::factory()->create()); + + $response = $this->put('/user/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $this->assertTrue(Hash::check('new-password', $user->fresh()->password)); + } + + public function test_current_password_must_be_correct(): void + { + $this->actingAs($user = User::factory()->create()); + + $response = $this->put('/user/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response->assertSessionHasErrors(); + + $this->assertTrue(Hash::check('password', $user->fresh()->password)); + } + + public function test_new_passwords_must_match(): void + { + $this->actingAs($user = User::factory()->create()); + + $response = $this->put('/user/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + + $this->assertTrue(Hash::check('password', $user->fresh()->password)); + } +}