├── public ├── favicon.ico ├── robots.txt ├── images │ └── default-user.png ├── mix-manifest.json ├── build │ └── manifest.json ├── .htaccess ├── web.config └── index.php ├── routes ├── api.php ├── console.php ├── channels.php ├── admin.php └── web.php ├── bootstrap ├── cache │ └── .gitignore └── app.php ├── storage ├── logs │ └── .gitignore ├── app │ ├── public │ │ └── .gitignore │ └── .gitignore ├── debugbar │ └── .gitignore ├── framework │ ├── testing │ │ └── .gitignore │ ├── views │ │ └── .gitignore │ ├── cache │ │ ├── data │ │ │ └── .gitignore │ │ └── .gitignore │ ├── sessions │ │ └── .gitignore │ └── .gitignore └── clockwork │ └── .gitignore ├── database ├── .gitignore ├── seeders │ ├── TeamSeeder.php │ ├── DatabaseSeeder.php │ └── UserSeeder.php ├── factories │ ├── TeamFactory.php │ ├── ActivityFactory.php │ └── UserFactory.php └── migrations │ ├── 2014_10_11_000000_create_teams_table.php │ ├── 2014_10_12_100000_create_password_resets_table.php │ ├── 2023_02_15_121808_create_team_user_table.php │ ├── 2023_02_15_122036_create_team_invitations_table.php │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ ├── 2021_12_22_121828_add_event_column_to_activity_log_table.php │ ├── 2021_12_22_121829_add_batch_uuid_column_to_activity_log_table.php │ ├── 2021_11_09_084508_create_jobs_table.php │ ├── 2014_10_12_000000_create_users_table.php │ └── 2021_12_22_121827_create_activity_log_table.php ├── tests ├── Feature │ └── Http │ │ └── Controllers │ │ ├── Admin │ │ ├── UserController │ │ │ └── EditTest.php │ │ └── DashboardControllerTest.php │ │ ├── DashboardControllerTest.php │ │ ├── ActivityControllerTest.php │ │ ├── Auth │ │ └── EmailVerificationPromptControllerTest.php │ │ └── Account │ │ └── ProfileControllerTest.php ├── CreatesApplication.php ├── Unit │ ├── Services │ │ ├── MemberNavTest.php │ │ └── AdminNavTest.php │ ├── Providers │ │ ├── EventServiceProviderTest.php │ │ └── AppServiceProviderTest.php │ ├── Rules │ │ └── PasswordCheckRuleTest.php │ ├── Notifications │ │ ├── PasswordUpdatedNotificationTest.php │ │ ├── EmailUpdateWarningNotificationTest.php │ │ └── EmailUpdateRequestNotificationTest.php │ ├── Http │ │ ├── Middleware │ │ │ └── AdminTest.php │ │ └── Requests │ │ │ └── Auth │ │ │ ├── PasswordResetLinkRequestTest.php │ │ │ ├── LoginRequestTest.php │ │ │ └── NewPasswordRequestTest.php │ ├── QueryBuilders │ │ ├── UserQueryBuilderTest.php │ │ └── FilterableTest.php │ ├── Models │ │ └── Concerns │ │ │ └── LogsDeleteActivityTest.php │ ├── Listeners │ │ └── ResizeImageListenerTest.php │ └── Components │ │ └── SidebarTest.php ├── TestCase.php └── helpers.php ├── config ├── castra.php ├── cors.php ├── services.php ├── view.php ├── activitylog.php ├── hashing.php └── broadcasting.php ├── resources ├── views │ ├── dashboards │ │ └── index.blade.php │ ├── admin │ │ └── dashboards │ │ │ └── index.blade.php │ ├── components │ │ ├── svg │ │ │ ├── chevron-up.blade.php │ │ │ ├── path.blade.php │ │ │ ├── chevron-down.blade.php │ │ │ ├── back.blade.php │ │ │ ├── hamburger.blade.php │ │ │ ├── svg.blade.php │ │ │ ├── file-upload.blade.php │ │ │ ├── edit.blade.php │ │ │ ├── dashboard.blade.php │ │ │ ├── users.blade.php │ │ │ ├── trash.blade.php │ │ │ ├── undo.blade.php │ │ │ ├── search.blade.php │ │ │ ├── error.blade.php │ │ │ ├── info.blade.php │ │ │ ├── warning.blade.php │ │ │ ├── success.blade.php │ │ │ ├── log.blade.php │ │ │ └── logo.blade.php │ │ ├── table │ │ │ ├── td.blade.php │ │ │ ├── th.blade.php │ │ │ ├── delete.blade.php │ │ │ ├── order-by.blade.php │ │ │ ├── search-button.blade.php │ │ │ ├── search-select.blade.php │ │ │ ├── search-input.blade.php │ │ │ ├── header-ordable.blade.php │ │ │ ├── filters.blade.php │ │ │ └── table.blade.php │ │ ├── error.blade.php │ │ ├── input.blade.php │ │ ├── button.blade.php │ │ ├── auth-session-status.blade.php │ │ ├── label.blade.php │ │ ├── label-checkbox.blade.php │ │ ├── dropdown-link.blade.php │ │ ├── layouts │ │ │ ├── guest.blade.php │ │ │ ├── auth.blade.php │ │ │ ├── sidebar.blade.php │ │ │ └── header.blade.php │ │ ├── status │ │ │ ├── inline.blade.php │ │ │ ├── body.blade.php │ │ │ └── layout.blade.php │ │ └── dropdown.blade.php │ ├── layouts │ │ ├── _profile-image.blade.php │ │ └── app.blade.php │ ├── errors │ │ ├── 401.blade.php │ │ ├── 429.blade.php │ │ ├── 500.blade.php │ │ ├── 403.blade.php │ │ └── 404.blade.php │ ├── accounts │ │ ├── _image.blade.php │ │ └── images │ │ │ └── _update_stream.blade.php │ └── auth │ │ ├── forgot-password.blade.php │ │ ├── verify-email.blade.php │ │ ├── reset-password.blade.php │ │ ├── register.blade.php │ │ └── login.blade.php ├── css │ └── app.css ├── js │ ├── app.js │ ├── turbo.js │ ├── bootstrap.js │ ├── alpine.js │ └── elements │ │ └── turbo-echo-stream-tag.js └── lang │ └── en │ ├── pagination.php │ ├── auth.php │ └── passwords.php ├── tformat.json ├── stubs ├── observer.plain.stub ├── test.unit.stub ├── model.pivot.stub ├── policy.plain.stub ├── controller.plain.stub ├── model.stub ├── test.stub ├── seeder.stub ├── provider.stub ├── job.stub ├── controller.invokable.stub ├── scope.stub ├── migration.stub ├── resource.stub ├── view-component.stub ├── console.stub ├── resource-collection.stub ├── rule.stub ├── middleware.stub ├── cast.inbound.stub ├── factory.stub ├── request.stub ├── migration.create.stub ├── migration.update.stub ├── job.queued.stub ├── controller.singleton.api.stub ├── cast.stub ├── observer.stub ├── controller.api.stub ├── event.stub ├── controller.model.api.stub ├── controller.singleton.stub ├── controller.nested.singleton.api.stub ├── markdown-notification.stub ├── controller.stub ├── mail.stub ├── markdown-mail.stub ├── policy.stub ├── controller.nested.api.stub ├── controller.model.stub ├── notification.stub ├── controller.nested.singleton.stub └── controller.nested.stub ├── .prettierrc ├── postcss.config.js ├── .gitattributes ├── app ├── Enums │ ├── UserRoles.php │ └── ActivityEvents.php ├── Models │ ├── Team.php │ ├── Concerns │ │ └── LogsDeleteActivity.php │ ├── Activity.php │ └── User.php ├── Http │ ├── Middleware │ │ ├── EncryptCookies.php │ │ ├── PreventRequestsDuringMaintenance.php │ │ ├── TrustHosts.php │ │ ├── TrimStrings.php │ │ ├── VerifyCsrfToken.php │ │ ├── Authenticate.php │ │ ├── Admin.php │ │ ├── TrustProxies.php │ │ └── RedirectIfAuthenticated.php │ ├── Controllers │ │ ├── DashboardController.php │ │ ├── Account │ │ │ ├── ProfileController.php │ │ │ ├── PasswordController.php │ │ │ ├── EmailController.php │ │ │ ├── VerifyNewEmailController.php │ │ │ └── ImageController.php │ │ ├── Admin │ │ │ ├── DashboardController.php │ │ │ └── UserController.php │ │ ├── RestoredItemController.php │ │ ├── Controller.php │ │ ├── Auth │ │ │ ├── EmailVerificationPromptController.php │ │ │ ├── EmailVerificationNotificationController.php │ │ │ ├── VerifyEmailController.php │ │ │ ├── PasswordResetLinkController.php │ │ │ ├── AuthenticatedSessionController.php │ │ │ ├── RegisteredUserController.php │ │ │ └── NewPasswordController.php │ │ ├── TeamController.php │ │ └── ActivityController.php │ └── Requests │ │ ├── StoreTeamRequest.php │ │ ├── UpdateTeamRequest.php │ │ ├── Auth │ │ ├── PasswordResetLinkRequest.php │ │ ├── RegisterUserRequest.php │ │ └── NewPasswordRequest.php │ │ ├── Account │ │ ├── UpdateImageRequest.php │ │ ├── UpdatePasswordRequest.php │ │ └── UpdateEmailRequest.php │ │ └── Admin │ │ └── StoreDeletedUserRequest.php ├── View │ └── Components │ │ ├── AppLayout.php │ │ └── Sidebar.php ├── Services │ ├── MemberNav.php │ ├── AdminNav.php │ └── SignedUrlGenerator.php ├── Providers │ ├── BroadcastServiceProvider.php │ ├── AuthServiceProvider.php │ ├── AppServiceProvider.php │ ├── EventServiceProvider.php │ ├── BladeServiceProvider.php │ └── RouteServiceProvider.php ├── Events │ └── ProfileImageUploaded.php ├── Console │ └── Kernel.php ├── Rules │ ├── PasswordCheckRule.php │ └── PasswordRule.php ├── Scopes │ └── VisibleToScope.php ├── Exceptions │ └── Handler.php ├── QueryBuilders │ ├── Filterable.php │ ├── UserQueryBuilder.php │ └── ActivityQueryBuilder.php ├── Listeners │ └── ResizeImageListener.php ├── Notifications │ ├── PasswordUpdatedNotification.php │ ├── EmailUpdateWarningNotification.php │ └── EmailUpdateRequestNotification.php └── helpers.php ├── .gitignore ├── vite.config.js ├── pint.json ├── .styleci.yml ├── .editorconfig ├── server.php ├── phpstan.neon ├── tailwind.config.js ├── .php-cs-fixer.dist.php ├── package.json ├── licence.md ├── .env.example ├── phpunit.xml ├── phpunit.xml.bak └── artisan /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | 10, 5 | ]; 6 | -------------------------------------------------------------------------------- /resources/views/dashboards/index.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /resources/views/admin/dashboards/index.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /tformat.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel", 3 | "disabled": ["SpaceAfterBladeDirectives"] 4 | } 5 | -------------------------------------------------------------------------------- /public/images/default-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jcergolj/castra/HEAD/public/images/default-user.png -------------------------------------------------------------------------------- /public/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/app.js": "/js/app.js", 3 | "/css/app.css": "/css/app.css" 4 | } 5 | -------------------------------------------------------------------------------- /stubs/observer.plain.stub: -------------------------------------------------------------------------------- 1 | '« Previous', 5 | 'next' => 'Next »', 6 | ]; 7 | -------------------------------------------------------------------------------- /resources/views/components/svg/chevron-up.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/svg/path.blade.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | *.js linguist-vendored 5 | CHANGELOG.md export-ignore 6 | -------------------------------------------------------------------------------- /resources/views/components/svg/chevron-down.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/js/turbo.js: -------------------------------------------------------------------------------- 1 | import * as Turbo from '@hotwired/turbo'; 2 | 3 | window.Turbo = Turbo; 4 | 5 | Turbo.start(); 6 | 7 | export default Turbo; 8 | -------------------------------------------------------------------------------- /resources/views/components/svg/back.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/svg/hamburger.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/table/td.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'px-5 py-5 bg-white text-sm']) }}> 2 | {{ $slot }} 3 | 4 | -------------------------------------------------------------------------------- /resources/views/layouts/_profile-image.blade.php: -------------------------------------------------------------------------------- 1 | Profile Image 2 | -------------------------------------------------------------------------------- /stubs/test.unit.stub: -------------------------------------------------------------------------------- 1 | 5 | {{ $message }} 6 |

7 | @enderror 8 | -------------------------------------------------------------------------------- /storage/framework/.gitignore: -------------------------------------------------------------------------------- 1 | compiled.php 2 | config.php 3 | down 4 | events.scanned.php 5 | maintenance.php 6 | routes.php 7 | routes.scanned.php 8 | schedule-* 9 | services.json 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /public/storage 3 | /vendor 4 | .env 5 | .env.backup 6 | .phpunit.result.cache 7 | docker-compose.override.yml 8 | npm-debug.log 9 | .php-cs-fixer.cache 10 | -------------------------------------------------------------------------------- /stubs/model.pivot.stub: -------------------------------------------------------------------------------- 1 | merge(['viewBox' => '0 0 24 24']) }}> 3 | {{ $slot }} 4 | 5 | -------------------------------------------------------------------------------- /app/Enums/ActivityEvents.php: -------------------------------------------------------------------------------- 1 | false]) 2 | 3 | merge(['class' => 'form-input w-full rounded-md focus:border-blue-600']) !!}> 4 | -------------------------------------------------------------------------------- /resources/views/components/svg/file-upload.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import laravel from 'laravel-vite-plugin'; 3 | 4 | export default defineConfig({ 5 | plugins: [laravel(['resources/css/app.css', 'resources/js/app.js'])], 6 | }); 7 | -------------------------------------------------------------------------------- /resources/views/components/button.blade.php: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /resources/views/components/svg/edit.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /stubs/policy.plain.stub: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/svg/users.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /stubs/controller.plain.stub: -------------------------------------------------------------------------------- 1 | merge(['class' => 'text-green-500 font-semibold']) }}> 5 | {{ $status['message'] }} 6 | 7 | @endif 8 | -------------------------------------------------------------------------------- /resources/views/components/label.blade.php: -------------------------------------------------------------------------------- 1 | @props(['value' => null]) 2 | 3 | 10 | -------------------------------------------------------------------------------- /resources/views/components/table/th.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider']) }}> 3 | {{ $slot }} 4 | 5 | -------------------------------------------------------------------------------- /database/seeders/TeamSeeder.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'inline-flex items-center']) }}> 4 | {{ $slot }} 5 | {{ $value }} 6 | 7 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | php: 2 | preset: laravel 3 | disabled: 4 | - no_unused_imports 5 | finder: 6 | not-name: 7 | - index.php 8 | - server.php 9 | js: 10 | finder: 11 | not-name: 12 | - webpack.mix.js 13 | css: true 14 | -------------------------------------------------------------------------------- /resources/views/components/svg/trash.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /stubs/model.stub: -------------------------------------------------------------------------------- 1 | 'These credentials do not match our records.', 5 | 'password' => 'The provided password is incorrect.', 6 | 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 7 | ]; 8 | -------------------------------------------------------------------------------- /resources/views/components/dropdown-link.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}> 3 | {{ $slot }} 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/table/delete.blade.php: -------------------------------------------------------------------------------- 1 | @props(['route' => null]) 2 | 3 |
4 | @csrf 5 | 8 |
9 | -------------------------------------------------------------------------------- /resources/views/components/svg/undo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/views/components/svg/search.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /app/Http/Middleware/EncryptCookies.php: -------------------------------------------------------------------------------- 1 | */ 10 | protected $except = []; 11 | } 12 | -------------------------------------------------------------------------------- /resources/views/components/table/order-by.blade.php: -------------------------------------------------------------------------------- 1 | @props(['orderBy', 'field', 'orderByDirection']) 2 | 3 | @if ($orderBy === $field && $orderByDirection === 'asc') 4 | 5 | @elseif ($orderBy === $field && $orderByDirection === 'desc') 6 | 7 | @endif 8 | -------------------------------------------------------------------------------- /stubs/seeder.stub: -------------------------------------------------------------------------------- 1 | call([ 12 | UserSeeder::class, 13 | ]); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/components/table/search-button.blade.php: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /resources/views/components/table/search-select.blade.php: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /public/build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources/css/app.css": { 3 | "file": "assets/app-ebe451a1.css", 4 | "src": "resources/css/app.css", 5 | "isEntry": true 6 | }, 7 | "resources/js/app.js": { 8 | "file": "assets/app-532e15ff.js", 9 | "src": "resources/js/app.js", 10 | "isEntry": true 11 | } 12 | } -------------------------------------------------------------------------------- /stubs/provider.stub: -------------------------------------------------------------------------------- 1 | */ 10 | protected $except = []; 11 | } 12 | -------------------------------------------------------------------------------- /app/Http/Controllers/Account/ProfileController.php: -------------------------------------------------------------------------------- 1 | 'Your password has been reset!', 5 | 'sent' => 'We have emailed your password reset link!', 6 | 'throttled' => 'Please wait before retrying.', 7 | 'token' => 'This password reset token is invalid.', 8 | 'user' => "We can't find a user with that email address.", 9 | ]; 10 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustHosts.php: -------------------------------------------------------------------------------- 1 | allSubdomainsOfApplicationUrl(), 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /stubs/controller.invokable.stub: -------------------------------------------------------------------------------- 1 | User::factory(), 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /stubs/migration.stub: -------------------------------------------------------------------------------- 1 | 'svg.dashboard', 'route' => 'dashboards.index', 'title' => 'Dashboard'], 11 | ['svg' => 'svg.log', 'route' => 'activities.index', 'title' => 'Log'], 12 | ]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrimStrings.php: -------------------------------------------------------------------------------- 1 | */ 10 | protected $except = [ 11 | 'current_password', 12 | 'password', 13 | 'password_confirmation', 14 | ]; 15 | } 16 | -------------------------------------------------------------------------------- /resources/views/errors/401.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $title = '401 - Unauthorized'; 3 | $message = $exception->getMessage() ?: 'You are not authorized.'; 4 | @endphp 5 | 6 | 7 |
8 |

{{ $title }}

9 |

{{ $message }}

10 |
11 |
12 | -------------------------------------------------------------------------------- /resources/views/errors/429.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $title = '429 - Too many requests'; 3 | $message = $exception->getMessage() ?: 'Too many requests.'; 4 | @endphp 5 | 6 | 7 |
8 |

{{ $title }}

9 |

{{ $message }}

10 |
11 |
12 | -------------------------------------------------------------------------------- /app/Http/Middleware/VerifyCsrfToken.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected $except = []; 15 | } 16 | -------------------------------------------------------------------------------- /app/Providers/BroadcastServiceProvider.php: -------------------------------------------------------------------------------- 1 | getMessage() ?: 'Something went wrong on our end.'; 4 | @endphp 5 | 6 | 7 |
8 |

{{ $title }}

9 |

{{ $message }}

10 |
11 |
12 | -------------------------------------------------------------------------------- /stubs/resource.stub: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | public function toArray(Request $request): array 14 | { 15 | return parent::toArray($request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /resources/views/components/svg/error.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/svg/info.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/svg/warning.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /resources/views/errors/403.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $title = '403 - Forbidden'; 3 | $message = $exception->getMessage() ?: 'You are not allowed to see this page.'; 4 | @endphp 5 | 6 | 7 |
8 |

{{ $title }}

9 |

{{ $message }}

10 |
11 |
12 | -------------------------------------------------------------------------------- /resources/views/errors/404.blade.php: -------------------------------------------------------------------------------- 1 | @php 2 | $title = 'Page not Found'; 3 | $message = $exception->getMessage() ?: 'Page you are looking for doesn\'t exists.'; 4 | @endphp 5 | 6 | 7 |
8 |

{{ $title }}

9 |

{{ $message }}

10 |
11 |
12 | -------------------------------------------------------------------------------- /app/Http/Requests/StoreTeamRequest.php: -------------------------------------------------------------------------------- 1 | search) }}" 2 | class="appearance-none rounded-r rounded-l sm:rounded-l-none border border-gray-400 border-b block pl-8 pr-6 py-2 w-full bg-white text-sm placeholder-gray-400 text-gray-700 focus:bg-white focus:placeholder-gray-600 focus:text-gray-700 focus:outline-none" 3 | placeholder="Search" /> 4 | -------------------------------------------------------------------------------- /app/Http/Controllers/RestoredItemController.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /stubs/console.stub: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | public function toArray(Request $request): array 14 | { 15 | return parent::toArray($request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/Providers/AuthServiceProvider.php: -------------------------------------------------------------------------------- 1 | */ 10 | protected $policies = []; 11 | 12 | public function boot(): void 13 | { 14 | $this->registerPolicies(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/Http/Middleware/Authenticate.php: -------------------------------------------------------------------------------- 1 | expectsJson()) { 13 | return route('login'); 14 | } 15 | 16 | return null; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/Http/Middleware/Admin.php: -------------------------------------------------------------------------------- 1 | user()->isAdmin()) { 14 | return $next($request); 15 | } 16 | 17 | abort(Response::HTTP_FORBIDDEN, 'You are not an admin.'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Http/Requests/Auth/PasswordResetLinkRequest.php: -------------------------------------------------------------------------------- 1 | ['required', 'email'], 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/js/bootstrap.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | window._ = _; 3 | 4 | /** 5 | * We'll load the axios HTTP library which allows us to easily issue requests 6 | * to our Laravel back-end. This library automatically handles sending the 7 | * CSRF token as a header based on the value of the "XSRF" token cookie. 8 | */ 9 | 10 | import axios from 'axios'; 11 | window.axios = axios; 12 | 13 | window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 14 | -------------------------------------------------------------------------------- /routes/admin.php: -------------------------------------------------------------------------------- 1 | name('admin.') 9 | ->group(function () { 10 | Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboards.index'); 11 | 12 | Route::resource('users', UserController::class)->only(['index', 'destroy']); 13 | }); 14 | -------------------------------------------------------------------------------- /stubs/middleware.stub: -------------------------------------------------------------------------------- 1 | 'svg.dashboard', 'route' => 'admin.dashboards.index', 'title' => 'Dashboard'], 11 | ['svg' => 'svg.users', 'route' => 'admin.users.index', 'title' => 'Users'], 12 | ['svg' => 'svg.log', 'route' => 'activities.index', 'title' => 'Log'], 13 | ]; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /stubs/cast.inbound.stub: -------------------------------------------------------------------------------- 1 | $attributes 12 | */ 13 | public function set(Model $model, string $key, mixed $value, array $attributes): mixed 14 | { 15 | return $value; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /stubs/factory.stub: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class {{ factory }}Factory extends Factory 11 | { 12 | /** 13 | * @return array 14 | */ 15 | public function definition(): array 16 | { 17 | return [ 18 | // 19 | ]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /stubs/request.stub: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function rules(): array 18 | { 19 | return [ 20 | // 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/CreatesApplication.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class)->bootstrap(); 19 | 20 | return $app; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Events/ProfileImageUploaded.php: -------------------------------------------------------------------------------- 1 | user = $user; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /resources/views/components/layouts/guest.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 | {{ __('Admin Dashboard') }} 7 |
8 | 9 | {{ $slot }} 10 | 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/views/components/svg/logo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | -------------------------------------------------------------------------------- /app/Services/SignedUrlGenerator.php: -------------------------------------------------------------------------------- 1 | $user->id, 'new_email' => $newEmail] 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stubs/migration.create.stub: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->timestamps(); 14 | }); 15 | } 16 | 17 | public function down(): void 18 | { 19 | Schema::dropIfExists('{{ table }}'); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /stubs/migration.update.stub: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 7 | 8 |
9 |
10 | {{ $slot }} 11 |
12 |
13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /resources/views/accounts/_image.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 | 5 |
6 | 7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/EmailVerificationPromptController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 14 | return redirect()->intended(RouteServiceProvider::HOME); 15 | } 16 | 17 | return view('auth.verify-email'); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Http/Requests/Account/UpdateImageRequest.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'required', 19 | 'image', 20 | 'dimensions:min_width=100,min_height=100', 21 | ], 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Requests/Auth/RegisterUserRequest.php: -------------------------------------------------------------------------------- 1 | ['required', 'string', 'email', 'max:255', 'unique:users'], 19 | 'password' => [new PasswordRule($this->password_confirmation)], 20 | ]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Http/Requests/Auth/NewPasswordRequest.php: -------------------------------------------------------------------------------- 1 | ['required'], 19 | 'email' => ['required', 'email'], 20 | 'password' => [new PasswordRule($this->password_confirmation)], 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Unit/Services/MemberNavTest.php: -------------------------------------------------------------------------------- 1 | build(); 16 | $expected = [ 17 | ['svg' => 'svg.dashboard', 'route' => 'dashboards.index', 'title' => 'Dashboard'], 18 | ]; 19 | 20 | $this->assertSame(sort($expected), sort($actual)); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/Console/Kernel.php: -------------------------------------------------------------------------------- 1 | command('inspire')->hourly(); 16 | } 17 | 18 | protected function commands(): void 19 | { 20 | $this->load(__DIR__.'/Commands'); 21 | 22 | require base_path('routes/console.php'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Rules/PasswordCheckRule.php: -------------------------------------------------------------------------------- 1 | user->password); 18 | } 19 | 20 | public function message(): string 21 | { 22 | return trans('validation.password_check'); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Scopes/VisibleToScope.php: -------------------------------------------------------------------------------- 1 | isAdmin()) { 18 | return $builder; 19 | } 20 | 21 | return $builder->where($this->field, user()->id); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | 7 | */ 8 | $uri = urldecode( 9 | parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) 10 | ); 11 | 12 | // This file allows us to emulate Apache's "mod_rewrite" functionality from the 13 | // built-in PHP web server. This provides a convenient way to test a Laravel 14 | // application without having installed a "real" web server software here. 15 | if ($uri !== '/' && file_exists(__DIR__.'/public'.$uri)) { 16 | return false; 17 | } 18 | 19 | require_once __DIR__.'/public/index.php'; 20 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/nunomaduro/larastan/extension.neon 3 | 4 | parameters: 5 | reportUnmatchedIgnoredErrors: false 6 | paths: 7 | - app 8 | 9 | # The level 8 is the highest level 10 | level: 5 11 | 12 | ignoreErrors: 13 | - '#Unsafe usage of new static#' 14 | - '#Access to an undefined property Spatie\\Activitylog\\Models\\Activity::\$event.#' 15 | - '#Access to an undefined property Spatie\\Activitylog\\Models\\Activity::\$properties.#' 16 | 17 | excludePaths: 18 | - ./*/*/FileToBeExcluded.php 19 | 20 | checkMissingIterableValueType: false 21 | -------------------------------------------------------------------------------- /stubs/job.queued.stub: -------------------------------------------------------------------------------- 1 | > */ 11 | protected $dontReport = []; 12 | 13 | /** @var array */ 14 | protected $dontFlash = [ 15 | 'current_password', 16 | 'password', 17 | 'password_confirmation', 18 | ]; 19 | 20 | public function register(): void 21 | { 22 | $this->reportable(function (Throwable $exception) { 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /resources/views/components/table/header-ordable.blade.php: -------------------------------------------------------------------------------- 1 | @props(['route' => null, 'orderBy' => null, 'orderByDirection' => 'asc', 'field' => null]) 2 | 3 | 5 |
6 |
7 | {{ $slot }} 8 |
9 |
10 | 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /tests/Unit/Providers/EventServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | assertListening(Registered::class, SendEmailVerificationNotification::class); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Unit/Rules/PasswordCheckRuleTest.php: -------------------------------------------------------------------------------- 1 | bcrypt('password')])); 16 | 17 | $this->assertTrue($rule->passes('password', 'password')); 18 | $this->assertFalse($rule->passes('password', 'invalid-password')); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /resources/views/components/status/inline.blade.php: -------------------------------------------------------------------------------- 1 | @props(['messageBag' => null]) 2 | 3 | @if (session('status') && session('status')['message_bag'] === $messageBag) 4 |

9 | {{ session('status')['message'] }} 10 |

11 | 12 | forget('status'); ?> 13 | @endif 14 | -------------------------------------------------------------------------------- /database/migrations/2014_10_11_000000_create_teams_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('user_id')->index(); 14 | $table->string('name'); 15 | $table->timestamps(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::dropIfExists('teams'); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | app->make(Request::class); 18 | 19 | return $request->setUserResolver(function () use ($user) { 20 | return $user; 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/js/alpine.js: -------------------------------------------------------------------------------- 1 | import Alpine from 'alpinejs'; 2 | 3 | document.addEventListener('turbo:before-render', () => { 4 | let permanents = document.querySelectorAll('[data-turbo-permanent]'); 5 | let undos = Array.from(permanents).map((el) => { 6 | el._x_ignore = true; 7 | return () => { 8 | delete el._x_ignore; 9 | }; 10 | }); 11 | 12 | document.addEventListener('turbo:render', function handler() { 13 | while (undos.length) undos.shift()(); 14 | document.removeEventListener('turbo:render', handler); 15 | }); 16 | }); 17 | 18 | window.Alpine = Alpine; 19 | 20 | Alpine.start(); 21 | 22 | export default Alpine; 23 | -------------------------------------------------------------------------------- /resources/views/components/status/body.blade.php: -------------------------------------------------------------------------------- 1 | @props(['color' => null]) 2 | 3 |
10 |
11 | 12 | {{ $title }} 13 | 14 |

15 | {{ $message }} 16 |

17 |
18 |
19 | -------------------------------------------------------------------------------- /stubs/cast.stub: -------------------------------------------------------------------------------- 1 | $attributes 12 | */ 13 | public function get(Model $model, string $key, mixed $value, array $attributes): mixed 14 | { 15 | return $value; 16 | } 17 | 18 | /** 19 | * @param array $attributes 20 | */ 21 | public function set(Model $model, string $key, mixed $value, array $attributes): mixed 22 | { 23 | return $value; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/helpers.php: -------------------------------------------------------------------------------- 1 | create($attributes); 11 | } 12 | 13 | /** 14 | * User seeder. 15 | */ 16 | function make_user($attributes = []): User 17 | { 18 | return User::factory()->make($attributes); 19 | } 20 | 21 | /** 22 | * Member seeder. 23 | */ 24 | function create_member($attributes = []): User 25 | { 26 | return create_user($attributes); 27 | } 28 | 29 | /** 30 | * Admin seeder. 31 | */ 32 | function create_admin($attributes = []): User 33 | { 34 | return User::factory()->admin()->create($attributes); 35 | } 36 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_100000_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 13 | $table->string('token'); 14 | $table->timestamp('created_at')->nullable(); 15 | }); 16 | } 17 | 18 | public function down(): void 19 | { 20 | Schema::dropIfExists('password_resets'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /resources/views/accounts/images/_update_stream.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{ config('app.name', 'Laravel') }} 11 | 12 | @vite(['resources/css/app.css', 'resources/js/app.js']) 13 | 14 | @stack('scripts') 15 | 16 | 17 | 18 | 19 | 20 | {{ $slot }} 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require('tailwindcss/defaultTheme'); 2 | 3 | module.exports = { 4 | content: [ 5 | './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', 6 | './storage/framework/views/*.php', 7 | './resources/views/**/*.blade.php', 8 | ], 9 | 10 | theme: { 11 | extend: { 12 | fontFamily: { 13 | sans: ['Nunito', ...defaultTheme.fontFamily.sans], 14 | }, 15 | }, 16 | }, 17 | 18 | variants: { 19 | extend: { 20 | opacity: ['disabled'], 21 | }, 22 | }, 23 | 24 | plugins: [require('@tailwindcss/forms')], 25 | }; 26 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /stubs/observer.stub: -------------------------------------------------------------------------------- 1 | app->isProduction()); 23 | 24 | if (env('PREVENT_STRAY_REQUESTS', false)) { 25 | Http::preventStrayRequests(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /stubs/controller.api.stub: -------------------------------------------------------------------------------- 1 | filter(function ($args, $method) { 12 | return method_exists($this, $method); 13 | })->each(function ($args, $method) { 14 | $this->call($method, $args); 15 | }); 16 | 17 | return $this; 18 | } 19 | 20 | protected function call($method, $args): mixed 21 | { 22 | if (is_array($args)) { 23 | return $this->$method($args[0], $args[1]); 24 | } 25 | 26 | return $this->$method($args); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Http/Requests/Admin/StoreDeletedUserRequest.php: -------------------------------------------------------------------------------- 1 | ['required', 'array'], 18 | 'ids.*' => [ 19 | 'exists:users,id', function ($attribute, $value, $fail) { 20 | if ($value === user()->id) { 21 | $fail('You cannot delete yourself.'); 22 | } 23 | }, 24 | ], 25 | ]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/Http/Requests/Account/UpdatePasswordRequest.php: -------------------------------------------------------------------------------- 1 | [ 20 | new PasswordRule($this->new_password_confirmation), 21 | ], 22 | 'current_password' => [ 23 | 'required', 24 | new PasswordCheckRule($this->user()), 25 | ], 26 | ]; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Unit/Services/AdminNavTest.php: -------------------------------------------------------------------------------- 1 | build(); 16 | $expected = [ 17 | ['svg' => 'svg.dashboard', 'route' => 'admin.dashboards.index', 'title' => 'Dashboard'], 18 | ['svg' => 'svg.users', 'route' => 'admin.users.index', 'title' => 'Users'], 19 | ['svg' => 'svg.log', 'route' => 'activities.index', 'title' => 'Log'], 20 | ]; 21 | 22 | $this->assertSame($expected, $actual); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | |string|null 14 | */ 15 | protected $proxies; 16 | 17 | /** 18 | * The headers that should be used to detect proxies. 19 | * 20 | * @var int 21 | */ 22 | protected $headers = Request::HEADER_X_FORWARDED_FOR | 23 | Request::HEADER_X_FORWARDED_HOST | 24 | Request::HEADER_X_FORWARDED_PORT | 25 | Request::HEADER_X_FORWARDED_PROTO | 26 | Request::HEADER_X_FORWARDED_AWS_ELB; 27 | } 28 | -------------------------------------------------------------------------------- /database/migrations/2023_02_15_121808_create_team_user_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('team_id'); 14 | $table->foreignId('user_id'); 15 | $table->string('role'); 16 | $table->timestamps(); 17 | 18 | $table->unique(['team_id', 'user_id']); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('team_user'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /app/Providers/EventServiceProvider.php: -------------------------------------------------------------------------------- 1 | > */ 14 | protected $listen = [ 15 | Registered::class => [ 16 | SendEmailVerificationNotification::class, 17 | ], 18 | ProfileImageUploaded::class => [ 19 | ResizeImageListener::class, 20 | ], 21 | ]; 22 | 23 | public function boot(): void 24 | { 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 9 | __DIR__.'/app', 10 | __DIR__.'/config', 11 | __DIR__.'/database', 12 | __DIR__.'/resources', 13 | __DIR__.'/routes', 14 | __DIR__.'/tests', 15 | ]) 16 | ->name('*.php') 17 | ->notName('*.blade.php') 18 | ->ignoreDotFiles(true) 19 | ->ignoreVCS(true); 20 | 21 | return (new Config()) 22 | ->setFinder($finder) 23 | ->registerCustomFixers([ 24 | new ForceFQCNFixer(), 25 | ]) 26 | ->setRules([ 27 | 'AdamWojs/phpdoc_force_fqcn_fixer' => true, 28 | ]) 29 | ->setRiskyAllowed(true) 30 | ->setUsingCache(true); 31 | -------------------------------------------------------------------------------- /app/Listeners/ResizeImageListener.php: -------------------------------------------------------------------------------- 1 | get("{$event->user->profile_image}"); 21 | 22 | Image::make($file) 23 | ->resize(200, 200) 24 | ->save(config('filesystems.disks.profile_image.root')."/{$event->user->profile_image}"); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/EmailVerificationNotificationController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 15 | return redirect()->intended(RouteServiceProvider::HOME); 16 | } 17 | 18 | $request->user()->sendEmailVerificationNotification(); 19 | 20 | msg('A new verification link has been sent to the email address you provided during registration.'); 21 | 22 | return back(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Http/Requests/Account/UpdateEmailRequest.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'required', 21 | 'email', 22 | Rule::unique('users', 'email')->ignore($this->user()->id), 23 | ], 24 | 'current_password' => [ 25 | 'required', 26 | new PasswordCheckRule($this->user()), 27 | ], 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /database/factories/ActivityFactory.php: -------------------------------------------------------------------------------- 1 | 'default', 16 | 'description' => 'here is a description', 17 | 'subject_type' => $user::class, 18 | 'event' => ActivityEvents::Deleted, 19 | 'subject_id' => $user->id, 20 | 'causer_type' => $user::class, 21 | 'causer_id' => $user->id, 22 | 'properties' => ['subject' => ['email' => 'joe.doe@example.com']], 23 | 'batch_uuid' => null, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/migrations/2023_02_15_122036_create_team_invitations_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->foreignId('team_id')->constrained()->cascadeOnDelete(); 14 | $table->string('email'); 15 | $table->string('role')->nullable(); 16 | $table->timestamps(); 17 | 18 | $table->unique(['team_id', 'email']); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('team_invitations'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /stubs/event.stub: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function broadcastOn(): array 26 | { 27 | return [ 28 | new PrivateChannel('channel-name'), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Http/Controllers/TeamController.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('uuid')->unique(); 14 | $table->text('connection'); 15 | $table->text('queue'); 16 | $table->longText('payload'); 17 | $table->longText('exception'); 18 | $table->timestamp('failed_at')->useCurrent(); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('failed_jobs'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /database/migrations/2021_12_22_121828_add_event_column_to_activity_log_table.php: -------------------------------------------------------------------------------- 1 | table(config('activitylog.table_name'), function (Blueprint $table) { 12 | $table->string('event')->nullable()->after('subject_type'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { 19 | $table->dropColumn('event'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2021_12_22_121829_add_batch_uuid_column_to_activity_log_table.php: -------------------------------------------------------------------------------- 1 | table(config('activitylog.table_name'), function (Blueprint $table) { 12 | $table->uuid('batch_uuid')->nullable()->after('properties'); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { 19 | $table->dropColumn('batch_uuid'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /stubs/controller.model.api.stub: -------------------------------------------------------------------------------- 1 | makeWith(AppServiceProvider::class, ['app' => $app]); 19 | Validator::shouldReceive('excludeUnvalidatedArrayKeys')->once(); 20 | 21 | $appServiceProvider->boot(); 22 | } 23 | 24 | /** @test */ 25 | public function models_are_unguarded() 26 | { 27 | $this->assertTrue(Model::isUnguarded()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Models/Concerns/LogsDeleteActivity.php: -------------------------------------------------------------------------------- 1 | event, static::$recordEvents)) { 25 | return; 26 | } 27 | 28 | $activity->event = ActivityEvents::Deleted->value; 29 | $activity->properties = ['subject' => $activity->subject]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/migrations/2021_11_09_084508_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 13 | $table->string('queue')->index(); 14 | $table->longText('payload'); 15 | $table->unsignedTinyInteger('attempts'); 16 | $table->unsignedInteger('reserved_at')->nullable(); 17 | $table->unsignedInteger('available_at'); 18 | $table->unsignedInteger('created_at'); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::dropIfExists('jobs'); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build", 6 | "format-js": "prettier --write 'resources/**/*.{css,js,vue}'", 7 | "format-blade": "prettier --write 'resources/**/*.blade.php'" 8 | }, 9 | "devDependencies": { 10 | "@hotwired/turbo": "^7.2", 11 | "@shufo/prettier-plugin-blade": "^1.0.11", 12 | "@tailwindcss/forms": "^0.5", 13 | "alpinejs": "^3.7.0", 14 | "autoprefixer": "^10.1.0", 15 | "axios": "^1.3", 16 | "laravel-echo": "^1.11.2", 17 | "laravel-vite-plugin": "^0.7.3", 18 | "lodash": "^4.17.19", 19 | "postcss": "^8.2.1", 20 | "postcss-import": "^15.1", 21 | "prettier": "^2.8", 22 | "pusher-js": "^8.0", 23 | "tailwindcss": "^3.2", 24 | "vite": "^4.0.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/VerifyEmailController.php: -------------------------------------------------------------------------------- 1 | user()->hasVerifiedEmail()) { 16 | return redirect()->intended(RouteServiceProvider::HOME.'?verified=1'); 17 | } 18 | 19 | if ($request->user()->markEmailAsVerified()) { 20 | event(new Verified($request->user())); 21 | } 22 | 23 | return redirect()->intended(RouteServiceProvider::HOME.'?verified=1'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/Providers/BladeServiceProvider.php: -------------------------------------------------------------------------------- 1 | header('Turbo-Frame') === null) 18 | { echo 'data-turbo-frame=\"_top\" target=\"_top\"'; } ?>"; 19 | }); 20 | 21 | Blade::directive('hasTurboFrameHeader', function () { 22 | return "header('Turbo-Frame') === null) { ?>"; 23 | }); 24 | 25 | Blade::directive('endHasTurboFrameHeader', function () { 26 | return ''; 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/QueryBuilders/UserQueryBuilder.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class UserQueryBuilder extends Builder 14 | { 15 | use Filterable; 16 | 17 | public function search(string|null $term = null): Builder 18 | { 19 | $this->when($term, function ($query, $term) { 20 | $query->where('email', 'LIKE', "%{$term}%"); 21 | }); 22 | 23 | return $this; 24 | } 25 | 26 | public function role(string|null $role = null): Builder 27 | { 28 | $this->when($role, function ($query, $role) { 29 | $query->where('role', UserRoles::from($role)); 30 | }); 31 | 32 | return $this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /stubs/controller.nested.singleton.api.stub: -------------------------------------------------------------------------------- 1 | user()->update(['password' => bcrypt($request->new_password)]); 21 | 22 | $request->user()->notify(new PasswordUpdatedNotification()); 23 | 24 | msg_success('Your password has been successfully updated.', 'update-password'); 25 | 26 | return to_route('accounts.profile'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Http/Middleware/RedirectIfAuthenticated.php: -------------------------------------------------------------------------------- 1 | check()) { 18 | continue; 19 | } 20 | 21 | /** @var \App\Models\User $user */ 22 | $user = Auth::user(); 23 | 24 | if ($user->isAdmin()) { 25 | return redirect(RouteServiceProvider::ADMIN_HOME); 26 | } 27 | 28 | return redirect(RouteServiceProvider::HOME); 29 | } 30 | 31 | return $next($request); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /stubs/markdown-notification.stub: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function via(object $notifiable): array 23 | { 24 | return ['mail']; 25 | } 26 | 27 | public function toMail(object $notifiable): MailMessage 28 | { 29 | return (new MailMessage)->markdown('{{ view }}'); 30 | } 31 | 32 | /** 33 | * @return array 34 | */ 35 | public function toArray(object $notifiable): array 36 | { 37 | return [ 38 | // 39 | ]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Notifications/PasswordUpdatedNotification.php: -------------------------------------------------------------------------------- 1 | subject('Your password has been changed') 23 | ->greeting('Hi there') 24 | ->line('I just want to let you know that your password has been changed.') 25 | ->line('If you didn\'t changed it. Please contact us immediately.'); 26 | } 27 | 28 | public function toArray(): array 29 | { 30 | return []; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /stubs/controller.stub: -------------------------------------------------------------------------------- 1 | 4 | 5 | @php 6 | $color = session('status')['color'] ?? null; 7 | $message = session('status')['message'] ?? null; 8 | $title = session('status')['title'] ?? null; 9 | $svg = session('status')['svg'] ?? null; 10 | $undo_url = session('status')['undo_url'] ?? null; 11 | @endphp 12 | 13 | @if ($color !== null) 14 | 15 | 16 | {{ $title }} 17 | 18 | 19 | 20 | {{ $message }} 21 | 22 | 23 | @endif 24 | 25 | -------------------------------------------------------------------------------- /stubs/mail.stub: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function attachments(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /config/cors.php: -------------------------------------------------------------------------------- 1 | ['api/*', 'sanctum/csrf-cookie'], 19 | 20 | 'allowed_methods' => ['*'], 21 | 22 | 'allowed_origins' => ['*'], 23 | 24 | 'allowed_origins_patterns' => [], 25 | 26 | 'allowed_headers' => ['*'], 27 | 28 | 'exposed_headers' => [], 29 | 30 | 'max_age' => 0, 31 | 32 | 'supports_credentials' => false, 33 | 34 | ]; 35 | -------------------------------------------------------------------------------- /stubs/markdown-mail.stub: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public function attachments(): array 39 | { 40 | return []; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /resources/views/auth/forgot-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | Forgot your password? 4 | No problem. 5 | Just let us know your email address and we will email 6 | you a password reset link that will allow you to choose a new one. 7 |
8 | 9 | 10 |
11 | @csrf 12 | 13 | 15 | 16 | 17 | 18 |
19 | 20 | Email Password Reset Link 21 | 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /database/migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 14 | $table->foreignId('current_team_id'); 15 | $table->string('email')->unique(); 16 | $table->timestamp('email_verified_at')->nullable(); 17 | $table->string('password'); 18 | $table->string('profile_image')->nullable(); 19 | $table->string('role')->default(UserRoles::Member->value); 20 | $table->rememberToken(); 21 | $table->timestamps(); 22 | $table->softDeletes(); 23 | }); 24 | } 25 | 26 | public function down(): void 27 | { 28 | Schema::dropIfExists('users'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/PasswordResetLinkController.php: -------------------------------------------------------------------------------- 1 | only('email')); 22 | 23 | if ($status === Password::RESET_LINK_SENT) { 24 | msg('We have emailed your password reset link!'); 25 | 26 | return back(); 27 | } 28 | 29 | return back()->withInput($request->only('email')) 30 | ->withErrors(['email' => __($status)]); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /stubs/policy.stub: -------------------------------------------------------------------------------- 1 | get(route('dashboards.index')) 15 | ->assertMiddlewareIsApplied('auth'); 16 | } 17 | 18 | /** @test */ 19 | public function verified_middleware_is_applied_to_the_index_request() 20 | { 21 | $this->get(route('dashboards.index')) 22 | ->assertMiddlewareIsApplied('verified'); 23 | } 24 | 25 | /** @test */ 26 | public function dashboard_index_view_can_be_rendered() 27 | { 28 | $response = $this->actingAs(create_user()) 29 | ->get(route('dashboards.index')); 30 | 31 | $response->assertStatus(Response::HTTP_OK) 32 | ->assertSee('Dashboard'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/Http/Controllers/ActivityController.php: -------------------------------------------------------------------------------- 1 | make(Activity::class); 14 | 15 | $activities = $activityModel->with(['causer', 'subject']) 16 | ->filter($request->only(['activity_event', 'search'])) 17 | ->orderBy($request->get('order_by', 'created_at'), $request->get('order_by_direction', 'desc')) 18 | ->paginate($request->get('per_page', config('castra.per_page'))); 19 | 20 | return view('activities.index', [ 21 | 'activities' => $activities, 22 | 'per_page' => $request->get('per_page', config('castra.per_page')), 23 | 'order_by' => $request->get('order_by', 'created_at'), 24 | 'order_by_direction' => $request->get('order_by_direction', 'desc'), 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /database/migrations/2021_12_22_121827_create_activity_log_table.php: -------------------------------------------------------------------------------- 1 | create(config('activitylog.table_name'), function (Blueprint $table) { 12 | $table->bigIncrements('id'); 13 | $table->string('log_name')->nullable(); 14 | $table->text('description'); 15 | $table->nullableMorphs('subject', 'subject'); 16 | $table->nullableMorphs('causer', 'causer'); 17 | $table->json('properties')->nullable(); 18 | $table->timestamps(); 19 | $table->index('log_name'); 20 | }); 21 | } 22 | 23 | public function down(): void 24 | { 25 | Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'domain' => env('MAILGUN_DOMAIN'), 19 | 'secret' => env('MAILGUN_SECRET'), 20 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 21 | ], 22 | 23 | 'postmark' => [ 24 | 'token' => env('POSTMARK_TOKEN'), 25 | ], 26 | 27 | 'ses' => [ 28 | 'key' => env('AWS_ACCESS_KEY_ID'), 29 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 30 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 31 | ], 32 | 33 | ]; 34 | -------------------------------------------------------------------------------- /stubs/controller.nested.api.stub: -------------------------------------------------------------------------------- 1 | authenticate(); 23 | 24 | $request->session()->regenerate(); 25 | 26 | return redirect()->intended(RouteServiceProvider::HOME); 27 | } 28 | 29 | public function destroy(Request $request): RedirectResponse 30 | { 31 | Auth::guard('web')->logout(); 32 | 33 | $request->session()->invalidate(); 34 | 35 | $request->session()->regenerateToken(); 36 | 37 | return redirect('/'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /stubs/controller.model.stub: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | public function via(object $notifiable): array 23 | { 24 | return ['mail']; 25 | } 26 | 27 | public function toMail(object $notifiable): MailMessage 28 | { 29 | return (new MailMessage) 30 | ->line('The introduction to the notification.') 31 | ->action('Notification Action', url('/')) 32 | ->line('Thank you for using our application!'); 33 | } 34 | 35 | /** 36 | * @return array 37 | */ 38 | public function toArray(object $notifiable): array 39 | { 40 | return [ 41 | // 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /licence.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Janez Cergolj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /resources/views/components/layouts/sidebar.blade.php: -------------------------------------------------------------------------------- 1 |
3 |
4 | 5 |
7 |
8 |
9 | 10 | Dashboard 11 |
12 |
13 | 14 | 23 |
24 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/RegisteredUserController.php: -------------------------------------------------------------------------------- 1 | $request->email, 28 | 'password' => Hash::make($request->password), 29 | ]); 30 | 31 | Auth::login($user); 32 | 33 | event(new Registered($user)); 34 | 35 | return redirect(RouteServiceProvider::HOME); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_URL=http://localhost 6 | 7 | LOG_CHANNEL=stack 8 | LOG_LEVEL=debug 9 | 10 | DB_CONNECTION=mysql 11 | DB_HOST=127.0.0.1 12 | DB_PORT=3306 13 | DB_DATABASE=example_app 14 | DB_USERNAME=root 15 | DB_PASSWORD= 16 | 17 | BROADCAST_DRIVER=log 18 | CACHE_DRIVER=file 19 | QUEUE_CONNECTION=sync 20 | SESSION_DRIVER=file 21 | SESSION_LIFETIME=120 22 | 23 | MEMCACHED_HOST=127.0.0.1 24 | 25 | REDIS_HOST=127.0.0.1 26 | REDIS_PASSWORD=null 27 | REDIS_PORT=6379 28 | 29 | MAIL_MAILER=smtp 30 | MAIL_HOST=mailhog 31 | MAIL_PORT=1025 32 | MAIL_USERNAME=null 33 | MAIL_PASSWORD=null 34 | MAIL_ENCRYPTION=null 35 | MAIL_FROM_ADDRESS=null 36 | MAIL_FROM_NAME="${APP_NAME}" 37 | 38 | AWS_ACCESS_KEY_ID= 39 | AWS_SECRET_ACCESS_KEY= 40 | AWS_DEFAULT_REGION=us-east-1 41 | AWS_BUCKET= 42 | 43 | PUSHER_APP_ID= 44 | PUSHER_APP_KEY= 45 | PUSHER_APP_SECRET= 46 | PUSHER_APP_CLUSTER=mt1 47 | 48 | MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 49 | MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 50 | 51 | ACTIVITY_LOGGER_DB_CONNECTION= 52 | CLOCKWORK_DATABASE_SLOW_THRESHOLD= 53 | CLOCKWORK_REQUESTS_SLOW_THRESHOLD= 54 | -------------------------------------------------------------------------------- /app/Notifications/EmailUpdateWarningNotification.php: -------------------------------------------------------------------------------- 1 | subject('Someone requested an email address change') 27 | ->greeting('Hi there') 28 | ->line('Someone has requested an email address update.') 29 | ->line("From {$notifiable->email} to {$this->newEmail}.") 30 | ->line('Wasn\'t you?') 31 | ->line('Please contact us immediately.'); 32 | } 33 | 34 | public function toArray(): array 35 | { 36 | return []; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Unit/Notifications/PasswordUpdatedNotificationTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ShouldQueue::class, $notification); 17 | } 18 | 19 | /** @test */ 20 | public function notification_is_sent_via_email() 21 | { 22 | $notification = new PasswordUpdatedNotification; 23 | 24 | $this->assertContains('mail', $notification->via(null)); 25 | } 26 | 27 | /** @test */ 28 | public function notification_contains_line() 29 | { 30 | $notification = new PasswordUpdatedNotification; 31 | $this->assertStringContainsString( 32 | 'password has been changed', 33 | $notification->toMail(null)->render() 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /database/seeders/UserSeeder.php: -------------------------------------------------------------------------------- 1 | isProduction()) { 14 | return; 15 | } 16 | 17 | User::factory() 18 | ->create([ 19 | 'email' => 'me@jcergolj.me.uk', 20 | 'email_verified_at' => now(), 21 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 22 | 'remember_token' => null, 23 | ]); 24 | 25 | User::factory() 26 | ->create([ 27 | 'email' => 'admin@jcergolj.me.uk', 28 | 'email_verified_at' => now(), 29 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 30 | 'remember_token' => null, 31 | 'role' => UserRoles::Admin, 32 | ]); 33 | 34 | User::factory() 35 | ->count(50) 36 | ->create(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resources/views/auth/verify-email.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | Thanks for signing up! 4 | Before getting started, could you verify your email address 5 | by clicking on the link we just emailed to you? 6 | If you didn't receive the email, we will gladly send you another. 7 |
8 | 9 | 10 | 11 |
12 |
14 | @csrf 15 | 16 |
17 | 18 | Resend Verification Email 19 | 20 |
21 |
22 | 23 |
24 | @method('DELETE') 25 | @csrf 26 | 27 | 28 | Log Out 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Unit 6 | 7 | 8 | ./tests/Feature 9 | 10 | 11 | 12 | 13 | ./app 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /phpunit.xml.bak: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/Unit 6 | 7 | 8 | ./tests/Feature 9 | 10 | 11 | 12 | 13 | ./app 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /stubs/controller.nested.singleton.stub: -------------------------------------------------------------------------------- 1 | [ 17 | resource_path('views'), 18 | ], 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Compiled View Path 23 | |-------------------------------------------------------------------------- 24 | | 25 | | This option determines where all the compiled Blade templates will be 26 | | stored for your application. Typically, this is within the storage 27 | | directory. However, as usual, you are free to change this value. 28 | | 29 | */ 30 | 31 | 'compiled' => env( 32 | 'VIEW_COMPILED_PATH', 33 | realpath(storage_path('framework/views')) 34 | ), 35 | 36 | ]; 37 | -------------------------------------------------------------------------------- /resources/views/components/table/filters.blade.php: -------------------------------------------------------------------------------- 1 | @props(['perPage', 'route' => null]) 2 | 3 |
4 | @csrf 5 |
6 |
7 |
8 | 9 | 11 | 13 | 15 | 16 |
17 | 18 | {{ $slot }} 19 |
20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /app/View/Components/Sidebar.php: -------------------------------------------------------------------------------- 1 | id === null) { 18 | $this->user = user(); 19 | } else { 20 | $this->user = $user; 21 | } 22 | } 23 | 24 | public function render(): View 25 | { 26 | $this->menu = $this->menuStrategy()->build(); 27 | 28 | return view('components.layouts.sidebar'); 29 | } 30 | 31 | /** @throws Exception */ 32 | public function menuStrategy(): mixed 33 | { 34 | $menus = [ 35 | 'admin' => 'AdminNav', 36 | 'member' => 'MemberNav', 37 | ]; 38 | 39 | if (! array_key_exists($this->user->role->value, $menus)) { 40 | throw new Exception('Nav class does not exists.'); 41 | } 42 | 43 | $class = "App\Services\\".$menus[$this->user->role->value]; 44 | 45 | return new $class(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 1, 15 | 'email' => $this->faker->unique()->safeEmail, 16 | 'email_verified_at' => now(), 17 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 18 | 'remember_token' => Str::random(10), 19 | 'profile_image' => null, 20 | 'role' => UserRoles::Member, 21 | ]; 22 | } 23 | 24 | public function unverified(): Factory 25 | { 26 | return $this->state(function (array $attributes) { 27 | return [ 28 | 'email_verified_at' => null, 29 | ]; 30 | }); 31 | } 32 | 33 | public function admin(): Factory 34 | { 35 | return $this->state(function (array $attributes) { 36 | return [ 37 | 'role' => UserRoles::Admin, 38 | ]; 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Http/Controllers/Account/EmailController.php: -------------------------------------------------------------------------------- 1 | new_email}. 24 | Please check your inbox for fuhrer instruction.", 25 | 'update-email' 26 | ); 27 | 28 | $request->user()?->notify(new EmailUpdateRequestNotification( 29 | Carbon::now()->addDay(), 30 | $request->new_email 31 | )); 32 | 33 | $request->user()?->notify(new EmailUpdateWarningNotification($request->new_email)); 34 | 35 | return to_route('accounts.profile'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Unit/Http/Middleware/AdminTest.php: -------------------------------------------------------------------------------- 1 | makeRequestWith(create_admin()); 17 | 18 | $expectedResponse = new Response('allowed', Response::HTTP_OK); 19 | $next = function () use ($expectedResponse) { 20 | return $expectedResponse; 21 | }; 22 | 23 | $actualResponse = (new Admin)->handle($request, $next); 24 | 25 | $this->assertSame($expectedResponse, $actualResponse); 26 | } 27 | 28 | /** @test */ 29 | public function do_not_allow_user_without_admin_role_to_continue() 30 | { 31 | $this->expectException(HttpException::class); 32 | 33 | $request = $this->makeRequestWith(create_member()); 34 | 35 | $response = (new Admin)->handle($request, function () { 36 | }); 37 | 38 | $response->assertStatus(Response::HTTP_FORBIDDEN); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/Models/Activity.php: -------------------------------------------------------------------------------- 1 | */ 18 | protected $casts = [ 19 | 'id' => 'integer', 20 | 'subject_id' => 'integer', 21 | 'causer_id' => 'integer', 22 | 'properties' => 'array', 23 | 'event' => ActivityEvents::class, 24 | ]; 25 | 26 | /** @param Builder $query */ 27 | public function newEloquentBuilder($query): ActivityQueryBuilder 28 | { 29 | return new ActivityQueryBuilder($query); 30 | } 31 | 32 | public static function getSubjectTypes(): Collection 33 | { 34 | return self::groupBy('subject_type')->get('subject_type')->pluck('subject_type'); 35 | } 36 | 37 | protected static function boot(): void 38 | { 39 | parent::boot(); 40 | static::addGlobalScope(new VisibleToScope('causer_id')); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /public/web.config: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /resources/views/components/table/table.blade.php: -------------------------------------------------------------------------------- 1 | @props(['bulkDeleteRoute' => null, 'items' => null]) 2 | 3 |
4 |
5 | 6 | 7 | 8 | 9 | @if ($bulkDeleteRoute !== null) 10 | 11 | 15 | 16 | @endif 17 | 18 | {{ $thead }} 19 | 20 | 21 | 22 | 23 | 24 | {{ $tbody }} 25 | 26 |
13 | 14 |
27 |
28 | {{ $items->withQueryString()->links() }} 29 |
30 |
31 |
32 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Admin/DashboardControllerTest.php: -------------------------------------------------------------------------------- 1 | get(route('admin.dashboards.index')) 15 | ->assertMiddlewareIsApplied('auth'); 16 | } 17 | 18 | /** @test */ 19 | public function verified_middleware_is_applied_to_the_index_request() 20 | { 21 | $this->get(route('admin.dashboards.index')) 22 | ->assertMiddlewareIsApplied('verified'); 23 | } 24 | 25 | /** @test */ 26 | public function admin_middleware_is_applied_to_the_index_request() 27 | { 28 | $this->get(route('admin.dashboards.index')) 29 | ->assertMiddlewareIsApplied('admin'); 30 | } 31 | 32 | /** @test */ 33 | public function dashboard_index_view_can_be_rendered() 34 | { 35 | $response = $this->actingAs(create_admin()) 36 | ->get(route('admin.dashboards.index')); 37 | 38 | $response->assertStatus(Response::HTTP_OK) 39 | ->assertSee('Dashboard'); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/js/elements/turbo-echo-stream-tag.js: -------------------------------------------------------------------------------- 1 | import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; 2 | 3 | const subscribeTo = (type, channel) => { 4 | if (type === 'presence') { 5 | return window.Echo.join(channel); 6 | } 7 | 8 | return window.Echo[type](channel); 9 | }; 10 | 11 | class TurboEchoStreamSourceElement extends HTMLElement { 12 | async connectedCallback() { 13 | connectStreamSource(this); 14 | this.subscription = subscribeTo(this.type, this.channel).listen( 15 | '.Tonysm\\TurboLaravel\\Events\\TurboStreamBroadcast', 16 | (e) => { 17 | this.dispatchMessageEvent(e.message); 18 | } 19 | ); 20 | } 21 | 22 | disconnectedCallback() { 23 | disconnectStreamSource(this); 24 | if (this.subscription) { 25 | window.Echo.leave(this.channel); 26 | this.subscription = null; 27 | } 28 | } 29 | 30 | dispatchMessageEvent(data) { 31 | const event = new MessageEvent('message', { data }); 32 | return this.dispatchEvent(event); 33 | } 34 | 35 | get channel() { 36 | return this.getAttribute('channel'); 37 | } 38 | 39 | get type() { 40 | return this.getAttribute('type') || 'private'; 41 | } 42 | } 43 | 44 | customElements.define('turbo-echo-stream-source', TurboEchoStreamSourceElement); 45 | -------------------------------------------------------------------------------- /stubs/controller.nested.stub: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class ActivityQueryBuilder extends Builder 14 | { 15 | use Filterable; 16 | 17 | public function event(string|null $event = null): Builder 18 | { 19 | $this->when($event, function ($query, $event) { 20 | $query->where('event', ActivityEvents::from($event)); 21 | }); 22 | 23 | return $this; 24 | } 25 | 26 | public function causerId(string|null $causer = null): Builder 27 | { 28 | $this->when($causer, function ($query, $causer) { 29 | $query->where('causer_id', $causer); 30 | }); 31 | 32 | return $this; 33 | } 34 | 35 | public function subjectType(string|null $subjectType = null): Builder 36 | { 37 | $this->when($subjectType, function ($query, $subjectType) { 38 | $query->where('subject_type', $subjectType); 39 | }); 40 | 41 | return $this; 42 | } 43 | 44 | public function logName(string|null $logName = null): Builder 45 | { 46 | $this->when($logName, function ($query, $logName) { 47 | $query->where('log_name', $logName); 48 | }); 49 | 50 | return $this; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Unit/QueryBuilders/UserQueryBuilderTest.php: -------------------------------------------------------------------------------- 1 | 'joe@example.com']); 20 | $jack = create_user(['email' => 'jack@example.com']); 21 | $users = User::search('joe@example.com')->get(); 22 | $this->assertCount(1, $users); 23 | $this->assertTrue($users->contains($joe)); 24 | $this->assertFalse($users->contains($jack)); 25 | } 26 | 27 | /** @test */ 28 | public function filter_by_role() 29 | { 30 | $member = create_user(['email' => 'member@example.com']); 31 | $admin = create_admin(['email' => 'admin@example.com']); 32 | 33 | $users = User::role(UserRoles::Admin->value)->get(); 34 | 35 | $this->assertCount(1, $users); 36 | $this->assertTrue($users->contains($admin)); 37 | $this->assertFalse($users->contains($member)); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/Http/Controllers/Account/VerifyNewEmailController.php: -------------------------------------------------------------------------------- 1 | logout(); 18 | 19 | abort_if(! $request->hasValidSignature(), Response::HTTP_FORBIDDEN); 20 | 21 | /** @var \App\Models\User $user */ 22 | $user = User::findOrFail($request->user); 23 | 24 | $user->update([ 25 | 'email' => $request->new_email, 26 | ]); 27 | 28 | $this->logActivity($request, $user); 29 | 30 | msg('Your email has been successfully updated.'); 31 | 32 | return to_route('login'); 33 | } 34 | 35 | private function logActivity(Request $request, User $user): void 36 | { 37 | activity() 38 | ->performedOn($user) 39 | ->causedBy($user) 40 | ->event(ActivityEvents::EmailUpdatedByUser->value) 41 | ->withProperties([ 42 | 'email' => $request->new_email, 43 | ]) 44 | ->log(ActivityEvents::EmailUpdatedByUser->value); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Unit/Notifications/EmailUpdateWarningNotificationTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ShouldQueue::class, $notification); 18 | } 19 | 20 | /** @test */ 21 | public function notification_is_sent_via_email() 22 | { 23 | $notification = new EmailUpdateWarningNotification('email@example.com'); 24 | 25 | $this->assertContains('mail', $notification->via(null)); 26 | } 27 | 28 | /** @test */ 29 | public function notification_contains_original_and_new_email_address() 30 | { 31 | $notification = new EmailUpdateWarningNotification('email@example.com'); 32 | 33 | $user = create_user(['email' => 'joe@example.com']); 34 | 35 | $this->assertStringContainsString( 36 | 'email@example.com', 37 | $notification->toMail($user)->render() 38 | ); 39 | 40 | $this->assertStringContainsString( 41 | 'joe@example.com', 42 | $notification->toMail($user)->render() 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Http/Controllers/Account/ImageController.php: -------------------------------------------------------------------------------- 1 | user()->profile_image !== null) { 24 | Storage::disk('profile_image') 25 | ->delete("{$request->user()->profile_image}"); 26 | } 27 | 28 | /** @var \Illuminate\Http\UploadedFile $profileImage */ 29 | $profileImage = $request->file('profile_image'); 30 | $path = $profileImage->store('/', 'profile_image'); 31 | 32 | $request->user()->saveImage($path); 33 | 34 | ProfileImageUploaded::dispatch($request->user()); 35 | 36 | msg_success('Your profile\'s image has been successfully updated.', 'update-image'); 37 | 38 | if ($request->wantsTurboStream()) { 39 | return response()->turboStreamView('accounts.images._update_stream'); 40 | } 41 | 42 | return to_route('accounts.profile'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/ActivityControllerTest.php: -------------------------------------------------------------------------------- 1 | get(route('activities.index')) 19 | ->assertMiddlewareIsApplied('auth'); 20 | } 21 | 22 | /** @test */ 23 | public function verified_middleware_is_applied_to_the_index_request() 24 | { 25 | $this->get(route('activities.index')) 26 | ->assertMiddlewareIsApplied('verified'); 27 | } 28 | 29 | /** @test */ 30 | public function index_view_can_be_rendered() 31 | { 32 | $activity = Activity::factory()->create(); 33 | 34 | $response = $this->actingAs(create_admin()) 35 | ->get(route('activities.index')); 36 | 37 | $response->assertStatus(Response::HTTP_OK) 38 | ->assertViewHas('activities') 39 | ->assertViewHas('per_page', config('castra.per_page')) 40 | ->assertViewHas('order_by', 'created_at') 41 | ->assertViewHas('order_by_direction', 'desc') 42 | ->assertSee($activity->event->value); 43 | } 44 | 45 | // user can't see causer_id 46 | 47 | // purge activities every 48 | } 49 | -------------------------------------------------------------------------------- /tests/Unit/Http/Requests/Auth/PasswordResetLinkRequestTest.php: -------------------------------------------------------------------------------- 1 | createFormRequest(PasswordResetLinkRequest::class) 18 | ->validate([ 19 | 'email' => 'joe@example.com', 20 | ]) 21 | ->assertPasses(); 22 | } 23 | 24 | /** 25 | * @test 26 | * 27 | * @dataProvider validationFailsProvider 28 | */ 29 | public function test_rules_fail($name, $value, $rule) 30 | { 31 | $this->createFormRequest(PasswordResetLinkRequest::class) 32 | ->validate([$name => $value]) 33 | ->assertFails([$name => $rule]); 34 | } 35 | 36 | public static function validationFailsProvider() 37 | { 38 | return [ 39 | 'Test email is required' => ['email', '', 'required'], 40 | 'Test email is valid email address' => ['email', 'not-valid-email-address', 'email'], 41 | ]; 42 | } 43 | 44 | /** @test */ 45 | public function request_is_allowed_for_all() 46 | { 47 | $request = new PasswordResetLinkRequest(); 48 | $this->assertTrue($request->authorize()); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /resources/views/auth/reset-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | @csrf 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 |
27 | 28 | Reset Password 29 | 30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /resources/views/components/dropdown.blade.php: -------------------------------------------------------------------------------- 1 | @props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white']) 2 | 3 | @php 4 | switch ($align) { 5 | case 'left': 6 | $alignmentClasses = 'origin-top-left left-0'; 7 | break; 8 | case 'top': 9 | $alignmentClasses = 'origin-top'; 10 | break; 11 | case 'right': 12 | default: 13 | $alignmentClasses = 'origin-top-right right-0'; 14 | break; 15 | } 16 | 17 | switch ($width) { 18 | case '48': 19 | $width = 'w-48'; 20 | break; 21 | } 22 | @endphp 23 | 24 |
26 |
27 | {{ $trigger }} 28 |
29 | 30 | 42 |
43 | -------------------------------------------------------------------------------- /tests/Unit/Notifications/EmailUpdateRequestNotificationTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(ShouldQueue::class, $notification); 19 | } 20 | 21 | /** @test */ 22 | public function notification_is_sent_via_email() 23 | { 24 | $notification = new EmailUpdateRequestNotification(Carbon::now(), 'email@example.com'); 25 | 26 | $this->assertContains('mail', $notification->via(null)); 27 | } 28 | 29 | /** @test */ 30 | public function notification_contains_temporally_signed_url() 31 | { 32 | $notification = new EmailUpdateRequestNotification(Carbon::now(), 'email@example.com'); 33 | 34 | $user = create_user(); 35 | 36 | $signedUrlGenerator = new SignedUrlGenerator(); 37 | $signedUrl = $signedUrlGenerator->forNewEmail($user, 'email@example.com', Carbon::now()); 38 | 39 | $this->assertStringContainsString( 40 | htmlspecialchars($signedUrl), 41 | $notification->toMail($user)->render() 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Providers/RouteServiceProvider.php: -------------------------------------------------------------------------------- 1 | configureRateLimiting(); 20 | 21 | $this->routes(function () { 22 | Route::prefix('api') 23 | ->middleware('api') 24 | ->namespace($this->namespace) 25 | ->group(base_path('routes/api.php')); 26 | 27 | Route::middleware('web') 28 | ->namespace($this->namespace) 29 | ->group(base_path('routes/web.php')); 30 | 31 | Route::middleware('web') 32 | ->namespace($this->namespace) 33 | ->group(base_path('routes/auth.php')); 34 | 35 | Route::middleware(['web', 'auth', 'verified', 'admin']) 36 | ->namespace($this->namespace) 37 | ->group(base_path('routes/admin.php')); 38 | }); 39 | } 40 | 41 | protected function configureRateLimiting(): void 42 | { 43 | RateLimiter::for('api', function (Request $request) { 44 | return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip()); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Unit/Models/Concerns/LogsDeleteActivityTest.php: -------------------------------------------------------------------------------- 1 | actingAs($admin = create_admin()); 25 | 26 | config(['activitylog.subject_returns_soft_deleted_models' => true]); 27 | 28 | $this->assertCount(0, Activity::get()); 29 | 30 | Schema::create('teams', function (Blueprint $table) { 31 | $table->bigIncrements('id'); 32 | }); 33 | 34 | $team = Team::create(); 35 | 36 | $teamId = $team->id; 37 | $team->delete(); 38 | 39 | $this->assertCount(1, Activity::get()); 40 | 41 | $activity = Activity::first(); 42 | 43 | $this->assertSame(ActivityEvents::Deleted, $activity->event); 44 | 45 | $this->assertTrue($activity->causer->is($admin)); 46 | 47 | $this->assertSame($teamId, $activity->subject_id); 48 | $this->assertSame(Team::class, $activity->subject_type); 49 | } 50 | } 51 | 52 | class Team extends Model 53 | { 54 | use LogsDeleteActivity; 55 | 56 | public $timestamps = false; 57 | } 58 | -------------------------------------------------------------------------------- /tests/Unit/QueryBuilders/FilterableTest.php: -------------------------------------------------------------------------------- 1 | getMockForTrait(Filterable::class, [], '', true, true, true, [ 15 | 'search', 16 | ]); 17 | 18 | $filterable 19 | ->expects($this->once()) 20 | ->method('search'); 21 | 22 | $result = $filterable->filter(['search' => true]); 23 | 24 | $this->assertSame($result, $filterable); 25 | } 26 | 27 | /** @test */ 28 | public function filter_array() 29 | { 30 | $filterable = $this->getMockForTrait(Filterable::class, [], '', true, true, true, [ 31 | 'search', 32 | ]); 33 | 34 | $filterable 35 | ->expects($this->once()) 36 | ->method('search'); 37 | 38 | $result = $filterable->filter(['search' => [true, true]]); 39 | 40 | $this->assertSame($result, $filterable); 41 | } 42 | 43 | /** @test */ 44 | public function filter_method_does_not_exist() 45 | { 46 | $filterable = $this->getMockForTrait(Filterable::class, [], '', true, true, true, [ 47 | 'call', 48 | ]); 49 | 50 | $filterable 51 | ->expects($this->never()) 52 | ->method('call'); 53 | 54 | $result = $filterable->filter(['search' => true]); 55 | 56 | $this->assertSame($result, $filterable); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /resources/views/auth/register.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | @csrf 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 32 | 33 |
34 | 35 | Register 36 | 37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Auth/EmailVerificationPromptControllerTest.php: -------------------------------------------------------------------------------- 1 | get(route('verification.notice')) 18 | ->assertMiddlewareIsApplied('auth'); 19 | } 20 | 21 | /** @test */ 22 | public function email_verification_view_can_be_rendered_if_user_is_not_verified() 23 | { 24 | $user = User::factory()->unverified()->create(); 25 | 26 | $response = $this->actingAs($user) 27 | ->get(route('verification.notice')); 28 | 29 | $response->assertStatus(Response::HTTP_OK) 30 | ->assertViewHasForm('id="resend_verification"', 'post', route('verification.send')); 31 | 32 | $response->assertStatus(Response::HTTP_OK) 33 | ->assertViewHasForm('id="logout"', 'delete', route('logout')); 34 | } 35 | 36 | /** @test */ 37 | public function already_verified_user_is_redirected_to_home_url() 38 | { 39 | $user = User::factory()->create([ 40 | 'email_verified_at' => Carbon::now(), 41 | ]); 42 | 43 | $response = $this->actingAs($user)->get(route('verification.notice')); 44 | 45 | $response->assertStatus(Response::HTTP_FOUND) 46 | ->assertRedirect(RouteServiceProvider::HOME); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/NewPasswordController.php: -------------------------------------------------------------------------------- 1 | $request]); 21 | } 22 | 23 | /** @throws ValidationException */ 24 | public function store(NewPasswordRequest $request): RedirectResponse 25 | { 26 | $status = $this->resetPassword($request); 27 | 28 | if ($status === Password::PASSWORD_RESET) { 29 | msg($status); 30 | 31 | return to_route('login'); 32 | } 33 | 34 | return back()->withInput($request->only('email')) 35 | ->withErrors(['email' => $status]); 36 | } 37 | 38 | private function resetPassword(Request $request): mixed 39 | { 40 | return Password::reset( 41 | $request->only('email', 'password', 'password_confirmation', 'token'), 42 | function ($user) use ($request) { 43 | $user->forceFill([ 44 | 'password' => Hash::make($request->password), 45 | 'remember_token' => Str::random(60), 46 | ])->save(); 47 | 48 | event(new PasswordReset($user)); 49 | } 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Unit/Http/Requests/Auth/LoginRequestTest.php: -------------------------------------------------------------------------------- 1 | createFormRequest(LoginRequest::class) 18 | ->validate([ 19 | 'email' => 'joe@example.com', 20 | 'password' => 'valid-password', 21 | ]) 22 | ->assertPasses(); 23 | } 24 | 25 | /** 26 | * @test 27 | * 28 | * @dataProvider validationFailsProvider 29 | */ 30 | public function test_login_rules_fail($name, $value, $rule) 31 | { 32 | $this->createFormRequest(LoginRequest::class) 33 | ->validate([$name => $value]) 34 | ->assertFails([$name => $rule]); 35 | } 36 | 37 | /** @test */ 38 | public function request_is_allowed_for_all() 39 | { 40 | $request = new LoginRequest(); 41 | $this->assertTrue($request->authorize()); 42 | } 43 | 44 | public static function validationFailsProvider() 45 | { 46 | return [ 47 | 'Test email is required' => ['email', '', 'required'], 48 | 'Test email is string' => ['email', 123, 'string'], 49 | 'Test email is valid email address' => ['email', 'not-valid-email-address', 'email'], 50 | 'Test password is required' => ['password', '', 'required'], 51 | 'Test password is string' => ['password', 123, 'string'], 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/activitylog.php: -------------------------------------------------------------------------------- 1 | env('ACTIVITY_LOGGER_ENABLED', true), 9 | 10 | /* 11 | * When the clean-command is executed, all recording activities older than 12 | * the number of days specified here will be deleted. 13 | */ 14 | 'delete_records_older_than_days' => 365, 15 | 16 | /* 17 | * If no log name is passed to the activity() helper 18 | * we use this default log name. 19 | */ 20 | 'default_log_name' => 'default', 21 | 22 | /* 23 | * You can specify an auth driver here that gets user models. 24 | * If this is null we'll use the default Laravel auth driver. 25 | */ 26 | 'default_auth_driver' => null, 27 | 28 | /* 29 | * If set to true, the subject returns soft deleted models. 30 | */ 31 | 'subject_returns_soft_deleted_models' => false, 32 | 33 | /* 34 | * This model will be used to log activity. 35 | * It should implement the Spatie\Activitylog\Contracts\Activity interface 36 | * and extend Illuminate\Database\Eloquent\Model. 37 | */ 38 | 'activity_model' => \Spatie\Activitylog\Models\Activity::class, 39 | 40 | /* 41 | * This is the name of the table that will be created by the migration and 42 | * used by the Activity model shipped with this package. 43 | */ 44 | 'table_name' => 'activity_log', 45 | 46 | /* 47 | * This is the database connection that will be used by the migration and 48 | * the Activity model shipped with this package. In case it's not set 49 | * Laravel's database.default will be used instead. 50 | */ 51 | 'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'), 52 | ]; 53 | -------------------------------------------------------------------------------- /config/hashing.php: -------------------------------------------------------------------------------- 1 | 'bcrypt', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Bcrypt Options 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may specify the configuration options that should be used when 26 | | passwords are hashed using the Bcrypt algorithm. This will allow you 27 | | to control the amount of time it takes to hash the given password. 28 | | 29 | */ 30 | 31 | 'bcrypt' => [ 32 | 'rounds' => env('BCRYPT_ROUNDS', 10), 33 | ], 34 | 35 | /* 36 | |-------------------------------------------------------------------------- 37 | | Argon Options 38 | |-------------------------------------------------------------------------- 39 | | 40 | | Here you may specify the configuration options that should be used when 41 | | passwords are hashed using the Argon algorithm. These will allow you 42 | | to control the amount of time it takes to hash the given password. 43 | | 44 | */ 45 | 46 | 'argon' => [ 47 | 'memory' => 1024, 48 | 'threads' => 2, 49 | 'time' => 2, 50 | ], 51 | 52 | ]; 53 | -------------------------------------------------------------------------------- /tests/Unit/Listeners/ResizeImageListenerTest.php: -------------------------------------------------------------------------------- 1 | assertListening( 24 | ProfileImageUploaded::class, 25 | ResizeImageListener::class 26 | ); 27 | } 28 | 29 | /** @test */ 30 | public function listener_is_queued() 31 | { 32 | $this->assertInstanceOf(ShouldQueue::class, (new ResizeImageListener())); 33 | } 34 | 35 | /** @test */ 36 | public function profile_image_is_resized() 37 | { 38 | Storage::disk('profile_image') 39 | ->putFileAs('', UploadedFile::fake()->image('abc123.jpg', 1000, 1000), 'abc123.jpg'); 40 | 41 | $event = new ProfileImageUploaded($user = create_user(['profile_image' => 'abc123.jpg'])); 42 | $listener = new ResizeImageListener(); 43 | $listener->handle($event); 44 | 45 | $imageProperties = getimagesize(config('filesystems.disks.profile_image.root')."/{$user->profile_image}"); 46 | 47 | $this->assertSame(200, $imageProperties[0]); 48 | $this->assertSame(200, $imageProperties[1]); 49 | 50 | Storage::disk('profile_image')->delete("{$user->profile_image}"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/helpers.php: -------------------------------------------------------------------------------- 1 | user(); 7 | } 8 | } 9 | 10 | if (! function_exists('trans_validation_attribute')) { 11 | function trans_validation_attribute(string $key): string 12 | { 13 | return trans("validation.attributes.{$key}"); 14 | } 15 | } 16 | 17 | if (! function_exists('msg')) { 18 | function msg(string $message, string $level = 'success', string|null $message_bag = null): void 19 | { 20 | $look_up = [ 21 | 'success' => 'sky', 22 | 'error' => 'sky', 23 | 'warning' => 'sky', 24 | 'info' => 'sky', 25 | ]; 26 | 27 | session()->flash( 28 | 'status', 29 | [ 30 | 'color' => $look_up[$level], 31 | 'message' => $message, 32 | 'message_bag' => $message_bag, 33 | ] 34 | ); 35 | } 36 | } 37 | 38 | if (! function_exists('msg_success')) { 39 | function msg_success(string $message, string|null $message_bag = null): void 40 | { 41 | msg($message, 'success', $message_bag); 42 | } 43 | } 44 | 45 | if (! function_exists('msg_error')) { 46 | function msg_error(string $message, string|null $message_bag = null): void 47 | { 48 | msg($message, 'error', $message_bag); 49 | } 50 | } 51 | 52 | if (! function_exists('msg_info')) { 53 | function msg_info(string $message, string|null $message_bag = null): void 54 | { 55 | msg($message, 'info', $message_bag); 56 | } 57 | } 58 | 59 | if (! function_exists('msg_warning')) { 60 | function msg_warning(string $message, string|null $message_bag = null): void 61 | { 62 | msg($message, 'warning', $message_bag); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/Notifications/EmailUpdateRequestNotification.php: -------------------------------------------------------------------------------- 1 | signedUrlGenerator = app(SignedUrlGenerator::class); 28 | $this->validUntil = $validUntil; 29 | $this->newEmail = $newEmail; 30 | } 31 | 32 | public function via(): array 33 | { 34 | return ['mail']; 35 | } 36 | 37 | public function toMail($notifiable): MailMessage 38 | { 39 | $signedUrl = $this->signedUrlGenerator->forNewEmail( 40 | $notifiable, 41 | $this->newEmail, 42 | $this->validUntil 43 | ); 44 | 45 | return (new MailMessage()) 46 | ->subject('Please confirm your new email') 47 | ->greeting('Hi there') 48 | ->line('Looks like you requested an email address change. Please confirm it by clicking on the link below.') 49 | ->action('Confirm email change', $signedUrl) 50 | ->line('If you didn\'t request it, please let us know.'); 51 | } 52 | 53 | public function toArray(): array 54 | { 55 | return []; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /resources/views/auth/login.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | @csrf 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | 24 |
25 | 26 | @if (Route::has('password.request')) 27 | 33 | @endif 34 |
35 | 36 |
37 | 38 | Log in 39 | 40 |
41 |
42 |
43 | -------------------------------------------------------------------------------- /tests/Unit/Http/Requests/Auth/NewPasswordRequestTest.php: -------------------------------------------------------------------------------- 1 | createFormRequest(NewPasswordRequest::class) 19 | ->validate([ 20 | 'token' => 'token', 21 | 'email' => 'joe@example.com', 22 | 'password' => 'password10', 23 | ]) 24 | ->assertPasses(); 25 | } 26 | 27 | /** 28 | * @test 29 | * 30 | * @dataProvider validationFailsProvider 31 | */ 32 | public function test_rules_fail($name, $value, $rule) 33 | { 34 | $this->createFormRequest(NewPasswordRequest::class) 35 | ->validate([$name => $value]) 36 | ->assertFails([$name => $rule]); 37 | } 38 | 39 | public static function validationFailsProvider() 40 | { 41 | return [ 42 | 'Test token is required' => ['token', '', 'required'], 43 | 'Test email is required' => ['email', '', 'required'], 44 | 'Test email is valid email address' => ['email', 'not-valid-email-address', 'email'], 45 | 'Test password does not have instance of password rule class' => ['password', '', PasswordRule::class], 46 | ]; 47 | } 48 | 49 | /** @test */ 50 | public function request_is_allowed_for_all() 51 | { 52 | $request = new NewPasswordRequest(); 53 | $this->assertTrue($request->authorize()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | singleton( 30 | Illuminate\Contracts\Http\Kernel::class, 31 | App\Http\Kernel::class 32 | ); 33 | 34 | $app->singleton( 35 | Illuminate\Contracts\Console\Kernel::class, 36 | App\Console\Kernel::class 37 | ); 38 | 39 | $app->singleton( 40 | Illuminate\Contracts\Debug\ExceptionHandler::class, 41 | App\Exceptions\Handler::class 42 | ); 43 | 44 | /* 45 | |-------------------------------------------------------------------------- 46 | | Return The Application 47 | |-------------------------------------------------------------------------- 48 | | 49 | | This script returns the application instance. The instance is given to 50 | | the calling script so we can separate the building of the instances 51 | | from the actual running of the application and sending responses. 52 | | 53 | */ 54 | 55 | return $app; 56 | -------------------------------------------------------------------------------- /resources/views/components/layouts/header.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 6 |
7 | 8 |
9 |
10 | 16 | 17 |
19 |
20 | 21 |
23 | 25 | Profile 26 | 27 |
28 | @csrf 29 | @method('DELETE') 30 | 31 | 35 | 36 |
37 |
38 |
39 |
40 |
41 | -------------------------------------------------------------------------------- /tests/Unit/Components/SidebarTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(MemberNav::class, $component->menuStrategy()); 18 | } 19 | 20 | /** @test */ 21 | public function admin_nav_is_returned_from_strategy() 22 | { 23 | $component = new Sidebar(create_admin()); 24 | $this->assertInstanceOf(AdminNav::class, $component->menuStrategy()); 25 | } 26 | 27 | /** @test */ 28 | public function member_nav_is_returned_from_strategy_for_auth_user() 29 | { 30 | $this->actingAs(create_user()); 31 | 32 | $component = $this->app->make(Sidebar::class); 33 | $this->assertInstanceOf(MemberNav::class, $component->menuStrategy()); 34 | } 35 | 36 | /** @test */ 37 | public function assert_menu_strategy_method_is_called() 38 | { 39 | $component = $this->getMockBuilder(Sidebar::class) 40 | ->setConstructorArgs(['user' => create_member()]) 41 | ->setMethods(['menuStrategy']) 42 | ->getMock(); 43 | 44 | $component->expects($this->once()) 45 | ->method('menuStrategy') 46 | ->willReturn(new MemberNav); 47 | 48 | $component->render(); 49 | } 50 | 51 | /** @test */ 52 | public function component_is_rendered() 53 | { 54 | $this->component(Sidebar::class, ['user' => create_member()]) 55 | ->assertSee(route('dashboards.index')) 56 | ->assertSeeText('Dashboard'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | make(Illuminate\Contracts\Console\Kernel::class); 34 | 35 | $status = $kernel->handle( 36 | $input = new Symfony\Component\Console\Input\ArgvInput, 37 | new Symfony\Component\Console\Output\ConsoleOutput 38 | ); 39 | 40 | /* 41 | |-------------------------------------------------------------------------- 42 | | Shutdown The Application 43 | |-------------------------------------------------------------------------- 44 | | 45 | | Once Artisan has finished running, we will fire off the shutdown events 46 | | so that any final work may be done by the application before we shut 47 | | down the process. This is the last thing to happen to the request. 48 | | 49 | */ 50 | 51 | $kernel->terminate($input, $status); 52 | 53 | exit($status); 54 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | make(Kernel::class); 50 | 51 | $response = tap($kernel->handle( 52 | $request = Request::capture() 53 | ))->send(); 54 | 55 | $kernel->terminate($request, $response); 56 | -------------------------------------------------------------------------------- /tests/Feature/Http/Controllers/Account/ProfileControllerTest.php: -------------------------------------------------------------------------------- 1 | get(route('accounts.profile')) 15 | ->assertMiddlewareIsApplied('auth'); 16 | } 17 | 18 | /** @test */ 19 | public function verified_middleware_is_applied_to_view() 20 | { 21 | $this->get(route('accounts.profile')) 22 | ->assertMiddlewareIsApplied('verified'); 23 | } 24 | 25 | /** @test */ 26 | public function profile_view_can_be_rendered() 27 | { 28 | $response = $this->actingAs(create_user()) 29 | ->get(route('accounts.profile')); 30 | 31 | $response->assertStatus(Response::HTTP_OK); 32 | } 33 | 34 | /** @test */ 35 | public function turbo_frame_update_password_exists_with_a_link() 36 | { 37 | $response = $this->actingAs(create_user()) 38 | ->get(route('accounts.profile')); 39 | 40 | $response->assertStatus(Response::HTTP_OK) 41 | ->assertElementHasChild( 42 | 'turbo-frame[id="frame_update_password"]', 43 | 'a[href="'.route('accounts.passwords.edit').'"]' 44 | ); 45 | } 46 | 47 | /** @test */ 48 | public function turbo_frame_update_email_exists_with_a_link() 49 | { 50 | $response = $this->actingAs(create_user()) 51 | ->get(route('accounts.profile')); 52 | 53 | $response->assertStatus(Response::HTTP_OK) 54 | ->assertElementHasChild( 55 | 'turbo-frame[id="frame_update_email"]', 56 | 'a[href="'.route('accounts.emails.edit').'"]' 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /config/broadcasting.php: -------------------------------------------------------------------------------- 1 | env('BROADCAST_DRIVER', 'null'), 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Broadcast Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the broadcast connections that will be used 26 | | to broadcast events to other systems or over websockets. Samples of 27 | | each available type of connection are provided inside this array. 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'pusher' => [ 34 | 'driver' => 'pusher', 35 | 'key' => env('PUSHER_APP_KEY'), 36 | 'secret' => env('PUSHER_APP_SECRET'), 37 | 'app_id' => env('PUSHER_APP_ID'), 38 | 'options' => [ 39 | 'cluster' => env('PUSHER_APP_CLUSTER'), 40 | 'useTLS' => true, 41 | ], 42 | ], 43 | 44 | 'ably' => [ 45 | 'driver' => 'ably', 46 | 'key' => env('ABLY_KEY'), 47 | ], 48 | 49 | 'redis' => [ 50 | 'driver' => 'redis', 51 | 'connection' => 'default', 52 | ], 53 | 54 | 'log' => [ 55 | 'driver' => 'log', 56 | ], 57 | 58 | 'null' => [ 59 | 'driver' => 'null', 60 | ], 61 | 62 | ], 63 | 64 | ]; 65 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | group(function () { 16 | Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboards.index'); 17 | 18 | Route::get('activities', [ActivityController::class, 'index'])->name('activities.index'); 19 | Route::post('restored-items', [RestoredItemController::class, 'store'])->name('restored-item.store'); 20 | 21 | Route::namespace('Account') 22 | ->prefix('account') 23 | ->name('accounts.') 24 | ->group(function () { 25 | Route::get('profile', [ProfileController::class, '__invoke'])->name('profile'); 26 | 27 | Route::get('password/edit', [PasswordController::class, 'edit'])->name('passwords.edit'); 28 | Route::patch('password', [PasswordController::class, 'update'])->name('passwords.update'); 29 | 30 | Route::get('email/edit', [EmailController::class, 'edit'])->name('emails.edit'); 31 | Route::patch('email', [EmailController::class, 'update'])->name('emails.update'); 32 | 33 | Route::get('profile-image/edit', [ImageController::class, 'edit'])->name('profile-images.edit'); 34 | Route::patch('profile-image', [ImageController::class, 'update'])->name('profile-images.update'); 35 | }); 36 | }); 37 | 38 | Route::get('account/verify-email', [VerifyNewEmailController::class, '__invoke']) 39 | ->middleware('throttle:6,1') 40 | ->name('accounts.verification.verify'); 41 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | */ 21 | protected $hidden = [ 22 | 'password', 23 | 'remember_token', 24 | ]; 25 | 26 | /** @var array */ 27 | protected $casts = [ 28 | 'id' => 'integer', 29 | 'email_verified_at' => 'datetime', 30 | 'deleted_at' => 'datetime', 31 | 'role' => UserRoles::class, 32 | ]; 33 | 34 | public function restoreRouteName(): string 35 | { 36 | return 'admin.users.restore'; 37 | } 38 | 39 | /** @param Builder $query */ 40 | public function newEloquentBuilder($query): UserQueryBuilder 41 | { 42 | return new UserQueryBuilder($query); 43 | } 44 | 45 | public function getProfileImageFileAttribute(): string 46 | { 47 | if ($this->profile_image === null) { 48 | return asset('images/default-user.png'); 49 | } 50 | 51 | return Storage::disk('profile_image')->url("{$this->profile_image}"); 52 | } 53 | 54 | public function saveImage(string $imageName): void 55 | { 56 | $this->update(['profile_image' => $imageName]); 57 | } 58 | 59 | public function isAdmin(): bool 60 | { 61 | return $this->role === UserRoles::Admin; 62 | } 63 | 64 | public function isItMe(): bool 65 | { 66 | return $this->is(user()); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/Http/Controllers/Admin/UserController.php: -------------------------------------------------------------------------------- 1 | filter($request->only(['search', 'role'])) 18 | ->orderBy($request->get('order_by', 'id'), $request->get('order_by_direction', 'asc')) 19 | ->paginate($request->get('per_page', config('castra.per_page'))); 20 | 21 | return view('admin.users.index', [ 22 | 'users' => $users, 23 | 'per_page' => $request->get('per_page', config('castra.per_page')), 24 | 'order_by' => $request->get('order_by', 'id'), 25 | 'order_by_direction' => $request->get('order_by_direction', 'asc'), 26 | ]); 27 | } 28 | 29 | public function destroy(User $user): RedirectResponse 30 | { 31 | abort_if(! $user->exists, Response::HTTP_NOT_FOUND, 'User do not exists.'); 32 | 33 | if ($user->isItMe()) { 34 | msg_error('You cannot delete yourself.'); 35 | 36 | return back(); 37 | } 38 | 39 | $user->delete(); 40 | 41 | msg_success('User has been successfully deleted.'); 42 | 43 | $request = Request::create(URL::previous()); 44 | 45 | $paginator = $user->select('id')->filter($request->only(['search', 'role'])) 46 | ->orderBy($request->get('order_by', 'id'), $request->get('order_by_direction', 'asc')) 47 | ->paginate($request->get('per_page', config('castra.per_page'))); 48 | 49 | $toPage = $paginator->lastPage() <= (int) $request->page ? $paginator->lastPage() : $request->page; 50 | $request->merge(['page' => $toPage]); 51 | 52 | return redirect($request->fullUrlWithQuery($request->all())); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/Rules/PasswordRule.php: -------------------------------------------------------------------------------- 1 | $value, 29 | $attribute.'_confirmation' => $this->confirmationValue, 30 | ], [ 31 | $attribute => $this->rules(), 32 | ]); 33 | 34 | try { 35 | $validator->validate(); 36 | } catch (ValidationException $exception) { 37 | $this->message = $validator->getMessageBag()->first(); 38 | $this->failedRules = $validator->failed(); 39 | 40 | return false; 41 | } 42 | 43 | return true; 44 | } 45 | 46 | public function message(): string 47 | { 48 | return $this->message; 49 | } 50 | 51 | public function passwordDefaultsRules(): void 52 | { 53 | Password::defaults(function () { 54 | $rule = Password::min(self::MIN_PASSWORD_LENGTH); 55 | 56 | return config('app.env') === 'production' 57 | ? $rule->mixedCase()->uncompromised() 58 | : $rule; 59 | }); 60 | } 61 | 62 | protected function rules(): array 63 | { 64 | $this->passwordDefaultsRules(); 65 | 66 | $rules = ['required', Password::defaults()]; 67 | 68 | if ($this->confirmationValue !== null) { 69 | $rules[] = 'confirmed'; 70 | } 71 | 72 | return $rules; 73 | } 74 | } 75 | --------------------------------------------------------------------------------