├── .editorconfig ├── .env.example ├── .gitattributes ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── app ├── Actions │ ├── Fortify │ │ ├── CreateNewUser.php │ │ ├── PasswordValidationRules.php │ │ ├── ResetUserPassword.php │ │ ├── UpdateUserPassword.php │ │ └── UpdateUserProfileInformation.php │ └── Jetstream │ │ ├── AddTeamMember.php │ │ ├── CreateTeam.php │ │ ├── DeleteTeam.php │ │ ├── DeleteUser.php │ │ ├── InviteTeamMember.php │ │ ├── RemoveTeamMember.php │ │ └── UpdateTeamName.php ├── Filament │ ├── Exports │ │ └── ItemExporter.php │ ├── Imports │ │ └── ItemImporter.php │ ├── Pages │ │ ├── Auth │ │ │ └── Login.php │ │ ├── LabelsGenerator.php │ │ └── Tenancy │ │ │ ├── EditTeamProfile.php │ │ │ └── RegisterTeam.php │ ├── Resources │ │ ├── ItemResource.php │ │ ├── ItemResource │ │ │ └── Pages │ │ │ │ ├── CreateItem.php │ │ │ │ ├── EditItem.php │ │ │ │ └── ListItems.php │ │ ├── LocationResource.php │ │ ├── LocationResource │ │ │ ├── Pages │ │ │ │ ├── CreateLocation.php │ │ │ │ ├── EditLocation.php │ │ │ │ ├── ListLocations.php │ │ │ │ └── ViewLocation.php │ │ │ └── RelationManagers │ │ │ │ ├── ItemsRelationManager.php │ │ │ │ └── LocationsRelationManager.php │ │ ├── TagResource.php │ │ └── TagResource │ │ │ ├── Pages │ │ │ ├── CreateTag.php │ │ │ ├── EditTag.php │ │ │ ├── ListTags.php │ │ │ └── ViewTag.php │ │ │ └── RelationManagers │ │ │ └── ItemsRelationManager.php │ └── Widgets │ │ ├── Labels.php │ │ ├── Locations.php │ │ ├── RecentlyAdded.php │ │ └── StatsOverviewWidget.php ├── Http │ ├── Controllers │ │ ├── Controller.php │ │ ├── ItemController.php │ │ └── LocationController.php │ ├── Middleware │ │ └── ApplyTenantScopes.php │ └── Resources │ │ ├── ItemResource.php │ │ └── LocationResource.php ├── Models │ ├── Item.php │ ├── Location.php │ ├── Membership.php │ ├── Tag.php │ ├── Team.php │ ├── TeamInvitation.php │ └── User.php ├── Policies │ ├── ItemPolicy.php │ ├── LocationPolicy.php │ └── TeamPolicy.php ├── Providers │ ├── AppServiceProvider.php │ ├── Filament │ │ └── AppPanelProvider.php │ ├── FortifyServiceProvider.php │ └── JetstreamServiceProvider.php └── View │ └── Components │ ├── AppLayout.php │ └── GuestLayout.php ├── artisan ├── bootstrap ├── app.php ├── cache │ └── .gitignore └── providers.php ├── bun.lockb ├── composer.json ├── composer.lock ├── config ├── app.php ├── auth.php ├── cache.php ├── database.php ├── filament.php ├── filesystems.php ├── fortify.php ├── jetstream.php ├── logging.php ├── mail.php ├── octane.php ├── queue.php ├── sanctum.php ├── services.php ├── session.php └── tags.php ├── database ├── .gitignore ├── factories │ ├── ItemFactory.php │ ├── LocationFactory.php │ ├── TagFactory.php │ ├── TeamFactory.php │ └── UserFactory.php ├── migrations │ ├── 0001_01_01_000000_create_users_table.php │ ├── 0001_01_01_000001_create_cache_table.php │ ├── 0001_01_01_000002_create_jobs_table.php │ ├── 2024_06_16_193714_add_two_factor_columns_to_users_table.php │ ├── 2024_06_16_194657_create_personal_access_tokens_table.php │ ├── 2024_06_16_194658_create_teams_table.php │ ├── 2024_06_16_194659_create_team_user_table.php │ ├── 2024_06_16_194700_create_team_invitations_table.php │ ├── 2024_06_17_081330_create_locations_table.php │ ├── 2024_06_17_200440_create_items_table.php │ ├── 2024_06_17_211601_create_notifications_table.php │ ├── 2024_06_17_211615_create_imports_table.php │ ├── 2024_06_17_211616_create_exports_table.php │ ├── 2024_06_17_211617_create_failed_import_rows_table.php │ ├── 2024_06_18_204247_add_team_id_to_items_table.php │ ├── 2024_06_18_204347_add_team_id_to_locations_table.php │ ├── 2024_06_21_151152_add_custom_fields_columns_to_items_table.php │ ├── 2024_06_21_174327_create_tag_tables.php │ ├── 2024_06_21_185152_add_team_id_to_tags_table.php │ └── 2024_06_24_202341_change_schema_for_items_table.php └── seeders │ └── DatabaseSeeder.php ├── docker-compose.wip.yml ├── docker-compose.yml ├── package.json ├── phpunit.xml ├── pint.json ├── postcss.config.js ├── public ├── .htaccess ├── css │ ├── filament │ │ ├── filament │ │ │ └── app.css │ │ ├── forms │ │ │ └── forms.css │ │ └── support │ │ │ └── support.css │ └── pxlrbt │ │ └── filament-spotlight │ │ └── spotlight-css.css ├── favicon.ico ├── index.php ├── js │ ├── app │ │ └── custom-script.js │ ├── filament │ │ ├── filament │ │ │ ├── app.js │ │ │ └── echo.js │ │ ├── forms │ │ │ └── components │ │ │ │ ├── color-picker.js │ │ │ │ ├── date-time-picker.js │ │ │ │ ├── file-upload.js │ │ │ │ ├── key-value.js │ │ │ │ ├── markdown-editor.js │ │ │ │ ├── rich-editor.js │ │ │ │ ├── select.js │ │ │ │ ├── tags-input.js │ │ │ │ └── textarea.js │ │ ├── notifications │ │ │ └── notifications.js │ │ ├── support │ │ │ ├── async-alpine.js │ │ │ └── support.js │ │ ├── tables │ │ │ └── components │ │ │ │ └── table.js │ │ └── widgets │ │ │ └── components │ │ │ ├── chart.js │ │ │ └── stats-overview │ │ │ └── stat │ │ │ └── chart.js │ └── pxlrbt │ │ └── filament-spotlight │ │ └── spotlight-js.js ├── robots.txt ├── welcome-hero.png └── welcome-person.jpg ├── resources ├── css │ └── app.css ├── js │ ├── app.js │ ├── bootstrap.js │ └── custom.filament.js ├── markdown │ ├── policy.md │ └── terms.md └── views │ ├── api │ ├── api-token-manager.blade.php │ └── index.blade.php │ ├── auth │ ├── confirm-password.blade.php │ ├── forgot-password.blade.php │ ├── login.blade.php │ ├── register.blade.php │ ├── reset-password.blade.php │ ├── two-factor-challenge.blade.php │ └── verify-email.blade.php │ ├── components │ ├── action-message.blade.php │ ├── action-section.blade.php │ ├── application-logo.blade.php │ ├── application-mark.blade.php │ ├── authentication-card-logo.blade.php │ ├── authentication-card.blade.php │ ├── banner.blade.php │ ├── button.blade.php │ ├── checkbox.blade.php │ ├── confirmation-modal.blade.php │ ├── confirms-password.blade.php │ ├── danger-button.blade.php │ ├── dialog-modal.blade.php │ ├── dropdown-link.blade.php │ ├── dropdown.blade.php │ ├── form-section.blade.php │ ├── input-error.blade.php │ ├── input.blade.php │ ├── label.blade.php │ ├── modal.blade.php │ ├── nav-link.blade.php │ ├── responsive-nav-link.blade.php │ ├── secondary-button.blade.php │ ├── section-border.blade.php │ ├── section-title.blade.php │ ├── switchable-team.blade.php │ ├── validation-errors.blade.php │ └── welcome.blade.php │ ├── dashboard.blade.php │ ├── emails │ └── team-invitation.blade.php │ ├── filament │ ├── pages │ │ └── label-generator.blade.php │ └── widgets │ │ └── labels.blade.php │ ├── layouts │ ├── app.blade.php │ └── guest.blade.php │ ├── navigation-menu.blade.php │ ├── policy.blade.php │ ├── profile │ ├── delete-user-form.blade.php │ ├── logout-other-browser-sessions-form.blade.php │ ├── show.blade.php │ ├── two-factor-authentication-form.blade.php │ ├── update-password-form.blade.php │ └── update-profile-information-form.blade.php │ ├── teams │ ├── create-team-form.blade.php │ ├── create.blade.php │ ├── delete-team-form.blade.php │ ├── show.blade.php │ ├── team-member-manager.blade.php │ └── update-team-name-form.blade.php │ ├── terms.blade.php │ └── welcome.blade.php ├── routes ├── api.php ├── console.php └── web.php ├── storage ├── app │ ├── .gitignore │ └── public │ │ └── .gitignore ├── debugbar │ └── .gitignore ├── framework │ ├── .gitignore │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ ├── sessions │ │ └── .gitignore │ ├── testing │ │ └── .gitignore │ └── views │ │ └── .gitignore └── logs │ └── .gitignore ├── tailwind.config.js ├── tests ├── Feature │ ├── ApiTokenPermissionsTest.php │ ├── AuthenticationTest.php │ ├── BrowserSessionsTest.php │ ├── CreateApiTokenTest.php │ ├── CreateTeamTest.php │ ├── DeleteAccountTest.php │ ├── DeleteApiTokenTest.php │ ├── DeleteTeamTest.php │ ├── EmailVerificationTest.php │ ├── ExampleTest.php │ ├── InviteTeamMemberTest.php │ ├── LeaveTeamTest.php │ ├── PasswordConfirmationTest.php │ ├── PasswordResetTest.php │ ├── ProfileInformationTest.php │ ├── RegistrationTest.php │ ├── RemoveTeamMemberTest.php │ ├── TwoFactorAuthenticationSettingsTest.php │ ├── UpdatePasswordTest.php │ ├── UpdateTeamMemberRoleTest.php │ └── UpdateTeamNameTest.php ├── TestCase.php └── Unit │ └── ExampleTest.php └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Inventory 2 | APP_ENV=local 3 | APP_KEY=base64:osYDLJr0A9kWYU+Qfut/BPUdXc8QSoGWtdbQIZx8id8= 4 | APP_DEBUG=false 5 | APP_TIMEZONE=UTC 6 | APP_URL=http://localhost 7 | 8 | APP_LOCALE=en 9 | APP_FALLBACK_LOCALE=en 10 | APP_FAKER_LOCALE=en_US 11 | 12 | APP_MAINTENANCE_DRIVER=file 13 | APP_MAINTENANCE_STORE=database 14 | 15 | BCRYPT_ROUNDS=12 16 | 17 | LOG_CHANNEL=stack 18 | LOG_STACK=single 19 | LOG_DEPRECATIONS_CHANNEL=null 20 | LOG_LEVEL=debug 21 | 22 | DB_CONNECTION=sqlite 23 | # DB_HOST=127.0.0.1 24 | # DB_PORT=3306 25 | # DB_DATABASE=laravel 26 | # DB_USERNAME=root 27 | # DB_PASSWORD= 28 | 29 | SESSION_DRIVER=database 30 | SESSION_LIFETIME=120 31 | SESSION_ENCRYPT=false 32 | SESSION_PATH=/ 33 | SESSION_DOMAIN=null 34 | 35 | BROADCAST_CONNECTION=log 36 | FILESYSTEM_DISK=local 37 | QUEUE_CONNECTION=database 38 | 39 | CACHE_STORE=database 40 | CACHE_PREFIX= 41 | 42 | MEMCACHED_HOST=127.0.0.1 43 | 44 | REDIS_CLIENT=phpredis 45 | REDIS_HOST=127.0.0.1 46 | REDIS_PASSWORD=null 47 | REDIS_PORT=6379 48 | 49 | MAIL_MAILER=log 50 | MAIL_HOST=127.0.0.1 51 | MAIL_PORT=2525 52 | MAIL_USERNAME=null 53 | MAIL_PASSWORD=null 54 | MAIL_ENCRYPTION=null 55 | MAIL_FROM_ADDRESS="hello@example.com" 56 | MAIL_FROM_NAME="${APP_NAME}" 57 | 58 | AWS_ACCESS_KEY_ID= 59 | AWS_SECRET_ACCESS_KEY= 60 | AWS_DEFAULT_REGION=us-east-1 61 | AWS_BUCKET= 62 | AWS_USE_PATH_STYLE_ENDPOINT=false 63 | 64 | VITE_APP_NAME="${APP_NAME}" 65 | 66 | # enable on Cloudflare if needed 67 | LARAVEL_CLOUDFLARE_ENABLED=false 68 | 69 | # we will use for backups in future 70 | FTP_HOST= 71 | FTP_USERNAME= 72 | FTP_PASSWORD= 73 | 74 | # we need it for storage 75 | CLOUDFLARE_R2_ACCESS_KEY_ID= 76 | CLOUDFLARE_R2_SECRET_ACCESS_KEY= 77 | CLOUDFLARE_R2_BUCKET= 78 | CLOUDFLARE_R2_ENDPOINT= 79 | CLOUDFLARE_R2_URL= 80 | 81 | # could be r2, s3 etc 82 | FILAMENT_FILESYSTEM_DISK=public 83 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.blade.php diff=html 4 | *.css diff=css 5 | *.html diff=html 6 | *.md diff=markdown 7 | *.php diff=php 8 | 9 | /.github export-ignore 10 | CHANGELOG.md export-ignore 11 | .styleci.yml export-ignore 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.phpunit.cache 2 | /node_modules 3 | /public/build 4 | /public/hot 5 | /public/storage 6 | /storage/*.key 7 | /vendor 8 | .env 9 | .env.backup 10 | .env.production 11 | .phpactor.json 12 | .phpunit.result.cache 13 | Homestead.json 14 | Homestead.yaml 15 | auth.json 16 | npm-debug.log 17 | yarn-error.log 18 | /.fleet 19 | /.idea 20 | /.vscode 21 | 22 | /caddy 23 | frankenphp 24 | frankenphp-worker.php 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dunglas/frankenphp 2 | 3 | ENV APP_ENV=production 4 | 5 | RUN install-php-extensions \ 6 | pdo_mysql \ 7 | gd \ 8 | intl \ 9 | zip \ 10 | opcache 11 | 12 | COPY . /app 13 | 14 | #WORKDIR /app 15 | # 16 | #HEALTHCHECK CMD curl --fail http://localhost:80/login || exit 1 17 | # 18 | #ENTRYPOINT ["php", "artisan", "octane:frankenphp"] 19 | -------------------------------------------------------------------------------- /app/Actions/Fortify/CreateNewUser.php: -------------------------------------------------------------------------------- 1 | $input 21 | */ 22 | public function create(array $input): User 23 | { 24 | Validator::make($input, [ 25 | 'name' => ['required', 'string', 'max:255'], 26 | 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 27 | 'password' => $this->passwordRules(), 28 | 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', 29 | ])->validate(); 30 | 31 | return DB::transaction(function () use ($input) { 32 | return tap(User::create([ 33 | 'name' => $input['name'], 34 | 'email' => $input['email'], 35 | 'password' => Hash::make($input['password']), 36 | ]), function (User $user) { 37 | $this->createTeam($user); 38 | }); 39 | }); 40 | } 41 | 42 | /** 43 | * Create a personal team for the user. 44 | */ 45 | protected function createTeam(User $user): void 46 | { 47 | $user->ownedTeams()->save(Team::forceCreate([ 48 | 'user_id' => $user->id, 49 | 'name' => explode(' ', $user->name, 2)[0]."'s Team", 50 | 'personal_team' => true, 51 | ])); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /app/Actions/Fortify/PasswordValidationRules.php: -------------------------------------------------------------------------------- 1 | |string> 13 | */ 14 | protected function passwordRules(): array 15 | { 16 | return ['required', 'string', Password::default(), 'confirmed']; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/Actions/Fortify/ResetUserPassword.php: -------------------------------------------------------------------------------- 1 | $input 18 | */ 19 | public function reset(User $user, array $input): void 20 | { 21 | Validator::make($input, [ 22 | 'password' => $this->passwordRules(), 23 | ])->validate(); 24 | 25 | $user->forceFill([ 26 | 'password' => Hash::make($input['password']), 27 | ])->save(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Actions/Fortify/UpdateUserPassword.php: -------------------------------------------------------------------------------- 1 | $input 18 | */ 19 | public function update(User $user, array $input): void 20 | { 21 | Validator::make($input, [ 22 | 'current_password' => ['required', 'string', 'current_password:web'], 23 | 'password' => $this->passwordRules(), 24 | ], [ 25 | 'current_password.current_password' => __('The provided password does not match your current password.'), 26 | ])->validateWithBag('updatePassword'); 27 | 28 | $user->forceFill([ 29 | 'password' => Hash::make($input['password']), 30 | ])->save(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/Actions/Fortify/UpdateUserProfileInformation.php: -------------------------------------------------------------------------------- 1 | $input 17 | */ 18 | public function update(User $user, array $input): void 19 | { 20 | Validator::make($input, [ 21 | 'name' => ['required', 'string', 'max:255'], 22 | 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 23 | 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 24 | ])->validateWithBag('updateProfileInformation'); 25 | 26 | if (isset($input['photo'])) { 27 | $user->updateProfilePhoto($input['photo']); 28 | } 29 | 30 | if ($input['email'] !== $user->email && 31 | $user instanceof MustVerifyEmail) { 32 | $this->updateVerifiedUser($user, $input); 33 | } else { 34 | $user->forceFill([ 35 | 'name' => $input['name'], 36 | 'email' => $input['email'], 37 | ])->save(); 38 | } 39 | } 40 | 41 | /** 42 | * Update the given verified user's profile information. 43 | * 44 | * @param array $input 45 | */ 46 | protected function updateVerifiedUser(User $user, array $input): void 47 | { 48 | $user->forceFill([ 49 | 'name' => $input['name'], 50 | 'email' => $input['email'], 51 | 'email_verified_at' => null, 52 | ])->save(); 53 | 54 | $user->sendEmailVerificationNotification(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/AddTeamMember.php: -------------------------------------------------------------------------------- 1 | authorize('addTeamMember', $team); 24 | 25 | $this->validate($team, $email, $role); 26 | 27 | $newTeamMember = Jetstream::findUserByEmailOrFail($email); 28 | 29 | AddingTeamMember::dispatch($team, $newTeamMember); 30 | 31 | $team->users()->attach( 32 | $newTeamMember, ['role' => $role] 33 | ); 34 | 35 | TeamMemberAdded::dispatch($team, $newTeamMember); 36 | } 37 | 38 | /** 39 | * Validate the add member operation. 40 | */ 41 | protected function validate(Team $team, string $email, ?string $role): void 42 | { 43 | Validator::make([ 44 | 'email' => $email, 45 | 'role' => $role, 46 | ], $this->rules(), [ 47 | 'email.exists' => __('We were unable to find a registered user with this email address.'), 48 | ])->after( 49 | $this->ensureUserIsNotAlreadyOnTeam($team, $email) 50 | )->validateWithBag('addTeamMember'); 51 | } 52 | 53 | /** 54 | * Get the validation rules for adding a team member. 55 | * 56 | * @return array 57 | */ 58 | protected function rules(): array 59 | { 60 | return array_filter([ 61 | 'email' => ['required', 'email', 'exists:users'], 62 | 'role' => Jetstream::hasRoles() 63 | ? ['required', 'string', new Role] 64 | : null, 65 | ]); 66 | } 67 | 68 | /** 69 | * Ensure that the user is not already on the team. 70 | */ 71 | protected function ensureUserIsNotAlreadyOnTeam(Team $team, string $email): Closure 72 | { 73 | return function ($validator) use ($team, $email) { 74 | $validator->errors()->addIf( 75 | $team->hasUserWithEmail($email), 76 | 'email', 77 | __('This user already belongs to the team.') 78 | ); 79 | }; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/CreateTeam.php: -------------------------------------------------------------------------------- 1 | $input 19 | */ 20 | public function create(User $user, array $input): Team 21 | { 22 | Gate::forUser($user)->authorize('create', Jetstream::newTeamModel()); 23 | 24 | Validator::make($input, [ 25 | 'name' => ['required', 'string', 'max:255'], 26 | ])->validateWithBag('createTeam'); 27 | 28 | AddingTeam::dispatch($user); 29 | 30 | $user->switchTeam($team = $user->ownedTeams()->create([ 31 | 'name' => $input['name'], 32 | 'personal_team' => false, 33 | ])); 34 | 35 | return $team; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/DeleteTeam.php: -------------------------------------------------------------------------------- 1 | purge(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/DeleteUser.php: -------------------------------------------------------------------------------- 1 | deleteTeams($user); 25 | $user->deleteProfilePhoto(); 26 | $user->tokens->each->delete(); 27 | $user->delete(); 28 | }); 29 | } 30 | 31 | /** 32 | * Delete the teams and team associations attached to the user. 33 | */ 34 | protected function deleteTeams(User $user): void 35 | { 36 | $user->teams()->detach(); 37 | 38 | $user->ownedTeams->each(function (Team $team) { 39 | $this->deletesTeams->delete($team); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/InviteTeamMember.php: -------------------------------------------------------------------------------- 1 | authorize('addTeamMember', $team); 27 | 28 | $this->validate($team, $email, $role); 29 | 30 | InvitingTeamMember::dispatch($team, $email, $role); 31 | 32 | $invitation = $team->teamInvitations()->create([ 33 | 'email' => $email, 34 | 'role' => $role, 35 | ]); 36 | 37 | Mail::to($email)->send(new TeamInvitation($invitation)); 38 | } 39 | 40 | /** 41 | * Validate the invite member operation. 42 | */ 43 | protected function validate(Team $team, string $email, ?string $role): void 44 | { 45 | Validator::make([ 46 | 'email' => $email, 47 | 'role' => $role, 48 | ], $this->rules($team), [ 49 | 'email.unique' => __('This user has already been invited to the team.'), 50 | ])->after( 51 | $this->ensureUserIsNotAlreadyOnTeam($team, $email) 52 | )->validateWithBag('addTeamMember'); 53 | } 54 | 55 | /** 56 | * Get the validation rules for inviting a team member. 57 | * 58 | * @return array 59 | */ 60 | protected function rules(Team $team): array 61 | { 62 | return array_filter([ 63 | 'email' => [ 64 | 'required', 'email', 65 | Rule::unique(Jetstream::teamInvitationModel())->where(function (Builder $query) use ($team) { 66 | $query->where('team_id', $team->id); 67 | }), 68 | ], 69 | 'role' => Jetstream::hasRoles() 70 | ? ['required', 'string', new Role] 71 | : null, 72 | ]); 73 | } 74 | 75 | /** 76 | * Ensure that the user is not already on the team. 77 | */ 78 | protected function ensureUserIsNotAlreadyOnTeam(Team $team, string $email): Closure 79 | { 80 | return function ($validator) use ($team, $email) { 81 | $validator->errors()->addIf( 82 | $team->hasUserWithEmail($email), 83 | 'email', 84 | __('This user already belongs to the team.') 85 | ); 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/RemoveTeamMember.php: -------------------------------------------------------------------------------- 1 | authorize($user, $team, $teamMember); 21 | 22 | $this->ensureUserDoesNotOwnTeam($teamMember, $team); 23 | 24 | $team->removeUser($teamMember); 25 | 26 | TeamMemberRemoved::dispatch($team, $teamMember); 27 | } 28 | 29 | /** 30 | * Authorize that the user can remove the team member. 31 | */ 32 | protected function authorize(User $user, Team $team, User $teamMember): void 33 | { 34 | if (! Gate::forUser($user)->check('removeTeamMember', $team) && 35 | $user->id !== $teamMember->id) { 36 | throw new AuthorizationException; 37 | } 38 | } 39 | 40 | /** 41 | * Ensure that the currently authenticated user does not own the team. 42 | */ 43 | protected function ensureUserDoesNotOwnTeam(User $teamMember, Team $team): void 44 | { 45 | if ($teamMember->id === $team->owner->id) { 46 | throw ValidationException::withMessages([ 47 | 'team' => [__('You may not leave a team that you created.')], 48 | ])->errorBag('removeTeamMember'); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/Actions/Jetstream/UpdateTeamName.php: -------------------------------------------------------------------------------- 1 | $input 17 | */ 18 | public function update(User $user, Team $team, array $input): void 19 | { 20 | Gate::forUser($user)->authorize('update', $team); 21 | 22 | Validator::make($input, [ 23 | 'name' => ['required', 'string', 'max:255'], 24 | ])->validateWithBag('updateTeamName'); 25 | 26 | $team->forceFill([ 27 | 'name' => $input['name'], 28 | ])->save(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/Filament/Exports/ItemExporter.php: -------------------------------------------------------------------------------- 1 | label('ID'), 19 | ExportColumn::make('ulid'), 20 | ExportColumn::make('name'), 21 | ExportColumn::make('description'), 22 | ExportColumn::make('import_ref'), 23 | ExportColumn::make('notes'), 24 | ExportColumn::make('quantity'), 25 | ExportColumn::make('insured'), 26 | ExportColumn::make('archived'), 27 | ExportColumn::make('asset_id')->label('Asset ID'), 28 | ExportColumn::make('serial_number'), 29 | ExportColumn::make('model_number'), 30 | ExportColumn::make('manufacturer'), 31 | ExportColumn::make('lifetime_warranty'), 32 | ExportColumn::make('warranty_expires'), 33 | ExportColumn::make('warranty_details'), 34 | ExportColumn::make('purchase_time'), 35 | ExportColumn::make('purchase_from'), 36 | ExportColumn::make('purchase_price'), 37 | ExportColumn::make('sold_time'), 38 | ExportColumn::make('sold_to'), 39 | ExportColumn::make('sold_price'), 40 | ExportColumn::make('sold_notes'), 41 | ExportColumn::make('location.name'), 42 | ExportColumn::make('deleted_at'), 43 | ExportColumn::make('created_at'), 44 | ExportColumn::make('updated_at'), 45 | ]; 46 | } 47 | 48 | public static function getCompletedNotificationBody(Export $export): string 49 | { 50 | $body = 'Your item export has completed and '.number_format($export->successful_rows).' '.str('row')->plural($export->successful_rows).' exported.'; 51 | 52 | if ($failedRowsCount = $export->getFailedRowsCount()) { 53 | $body .= ' '.number_format($failedRowsCount).' '.str('row')->plural($failedRowsCount).' failed to export.'; 54 | } 55 | 56 | return $body; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/Filament/Pages/Auth/Login.php: -------------------------------------------------------------------------------- 1 | form->fill([ 14 | 'email' => 'demo@example.com', 15 | 'password' => 'demo@example.com', 16 | 'remember' => true, 17 | ]); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Filament/Pages/Tenancy/EditTeamProfile.php: -------------------------------------------------------------------------------- 1 | schema([ 20 | TextInput::make('name')->required(), 21 | // ... 22 | ]); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Filament/Pages/Tenancy/RegisterTeam.php: -------------------------------------------------------------------------------- 1 | schema([ 20 | TextInput::make('name')->required(), 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Filament/Resources/ItemResource/Pages/CreateItem.php: -------------------------------------------------------------------------------- 1 | button() 23 | ->label('QR Code') 24 | ->icon('heroicon-o-qr-code') 25 | ->modalHeading('QR Code:') 26 | ->action(function () {}) 27 | ->modalSubmitAction(false) 28 | ->modalWidth(MaxWidth::Small) 29 | ->modalCancelAction(fn ($action) => $action->label('Close')) 30 | ->infolist([ 31 | ImageEntry::make('qr_code_url')->size(300)->label(''), 32 | ]), 33 | 34 | DeleteAction::make(), 35 | ForceDeleteAction::make(), 36 | RestoreAction::make(), 37 | ]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/Filament/Resources/ItemResource/Pages/ListItems.php: -------------------------------------------------------------------------------- 1 | schema([ 19 | Forms\Components\TextInput::make('name') 20 | ->required() 21 | ->maxLength(255), 22 | ]); 23 | } 24 | 25 | public function table(Table $table): Table 26 | { 27 | return ItemResource::table($table); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Filament/Resources/LocationResource/RelationManagers/LocationsRelationManager.php: -------------------------------------------------------------------------------- 1 | schema([ 22 | Forms\Components\TextInput::make('name') 23 | ->required() 24 | ->maxLength(255), 25 | ]); 26 | } 27 | 28 | public function table(Table $table): Table 29 | { 30 | return $table 31 | ->recordTitleAttribute('name') 32 | ->recordUrl(fn ($record) => LocationResource::getUrl('edit', ['record' => $record])) 33 | ->columns([ 34 | Tables\Columns\TextColumn::make('name'), 35 | ]) 36 | ->filters([ 37 | // 38 | ]) 39 | ->headerActions([ 40 | Tables\Actions\CreateAction::make(), 41 | ]) 42 | ->actions([ 43 | Tables\Actions\EditAction::make(), 44 | Tables\Actions\DeleteAction::make(), 45 | ]) 46 | ->bulkActions([ 47 | Tables\Actions\BulkActionGroup::make([ 48 | Tables\Actions\DeleteBulkAction::make(), 49 | ]), 50 | ]); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/Filament/Resources/TagResource.php: -------------------------------------------------------------------------------- 1 | schema([ 35 | TextInput::make('name.en') 36 | ->label('Name') 37 | ->required() 38 | ->autofocus(), 39 | ]); 40 | } 41 | 42 | public static function table(Table $table): Table 43 | { 44 | return $table 45 | ->columns([ 46 | TextColumn::make('name') 47 | ->label('Name') 48 | ->searchable(), 49 | ]) 50 | ->filters([ 51 | // 52 | ]) 53 | ->actions([ 54 | EditAction::make(), 55 | DeleteAction::make(), 56 | ]) 57 | ->bulkActions([ 58 | BulkActionGroup::make([ 59 | DeleteBulkAction::make(), 60 | ]), 61 | ]); 62 | } 63 | 64 | public static function getPages(): array 65 | { 66 | return [ 67 | 'index' => Pages\ListTags::route('/'), 68 | // 'create' => Pages\CreateTag::route('/create'), 69 | // 'edit' => Pages\EditTag::route('/{record}/edit'), 70 | 'view' => Pages\ViewTag::route('/{record}'), 71 | ]; 72 | } 73 | 74 | public static function getGloballySearchableAttributes(): array 75 | { 76 | return [ 77 | 'name', 78 | ]; 79 | } 80 | 81 | public static function getRelations(): array 82 | { 83 | return [ 84 | ItemsRelationManager::class, 85 | ]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/Filament/Resources/TagResource/Pages/CreateTag.php: -------------------------------------------------------------------------------- 1 | schema([ 19 | Forms\Components\TextInput::make('name') 20 | ->required() 21 | ->maxLength(255), 22 | ]); 23 | } 24 | 25 | public function table(Table $table): Table 26 | { 27 | return ItemResource::table($table); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Filament/Widgets/Labels.php: -------------------------------------------------------------------------------- 1 | query(TagResource::getEloquentQuery()) 21 | ->contentGrid([ 22 | 'md' => 3, 23 | 'lg' => 4, 24 | 'xl' => 5, 25 | ]) 26 | ->columns([ 27 | 28 | \Filament\Tables\Columns\Layout\View::make('filament.widgets.labels'), 29 | ]) 30 | ->recordUrl( 31 | fn (Tag $record): string => \App\Filament\Resources\TagResource::getUrl('view', ['record' => $record]), 32 | ) 33 | ->paginated([ 34 | 'limit' => 100, 35 | ]) 36 | ->defaultPaginationPageOption(100) 37 | ->actions([]) 38 | ->bulkActions([]) 39 | ->emptyStateHeading('No labels found') 40 | ->emptyStateDescription('Create a new label to get started.') 41 | ->emptyStateIcon('heroicon-o-tag') 42 | ->modifyQueryUsing(fn (Builder $query) => $query->withCount('items')) 43 | ->defaultSort('items_count', 'desc'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Filament/Widgets/Locations.php: -------------------------------------------------------------------------------- 1 | query(LocationResource::getEloquentQuery()->where('parent_id', null)) 23 | ->modifyQueryUsing(fn (Builder $query) => $query->withCount('items')) 24 | ->defaultPaginationPageOption(15) 25 | ->defaultSort('created_at', 'desc') 26 | ->columns([ 27 | Stack::make([ 28 | Tables\Columns\TextColumn::make('name') 29 | ->searchable() 30 | ->suffix(fn (Location $record 31 | ): string => $record->items_count > 0 ? ' ('.$record->items_count.' items)' : ''), 32 | ]), 33 | ]) 34 | ->contentGrid([ 35 | 'md' => 2, 36 | 'xl' => 3, 37 | ]) 38 | ->actions([ 39 | Tables\Actions\Action::make('open') 40 | ->url(fn (Location $record): string => LocationResource::getUrl('edit', ['record' => $record])), 41 | ]); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/Filament/Widgets/StatsOverviewWidget.php: -------------------------------------------------------------------------------- 1 | filters['startDate'] ?? null) ? 23 | Carbon::parse($this->filters['startDate']) : 24 | null; 25 | 26 | $endDate = ! is_null($this->filters['endDate'] ?? null) ? 27 | Carbon::parse($this->filters['endDate']) : 28 | now(); 29 | 30 | $isBusinessCustomersOnly = $this->filters['businessCustomersOnly'] ?? null; 31 | $businessCustomerMultiplier = match (true) { 32 | boolval($isBusinessCustomersOnly) => 2 / 3, 33 | blank($isBusinessCustomersOnly) => 1, 34 | default => 1 / 3, 35 | }; 36 | 37 | $diffInDays = $startDate ? $startDate->diffInDays($endDate) : 0; 38 | 39 | $revenue = (int) (($startDate ? ($diffInDays * 137) : 192100) * $businessCustomerMultiplier); 40 | $newCustomers = (int) (($startDate ? ($diffInDays * 7) : 1340) * $businessCustomerMultiplier); 41 | $newOrders = (int) (($startDate ? ($diffInDays * 13) : 3543) * $businessCustomerMultiplier); 42 | 43 | $formatNumber = function (int $number): string { 44 | if ($number < 1000) { 45 | return (string) Number::format($number, 0); 46 | } 47 | 48 | if ($number < 1000000) { 49 | return Number::format($number / 1000, 2).'k'; 50 | } 51 | 52 | return Number::format($number / 1000000, 2).'m'; 53 | }; 54 | 55 | $totalValue = Item::sum('purchase_price'); 56 | 57 | return [ 58 | Stat::make('Total Value', '$'.$formatNumber($totalValue)), 59 | Stat::make('Total Items', Item::count()) 60 | ->description('10% increase') 61 | ->descriptionIcon('heroicon-m-arrow-trending-up') 62 | ->chart([7, 2, 10, 3, 15, 4, 17]) 63 | ->color('success'), 64 | Stat::make('Total Locations', Location::count()) 65 | ->description('3% decrease') 66 | ->descriptionIcon('heroicon-m-arrow-trending-down') 67 | ->chart([17, 16, 14, 15, 14, 13, 12]) 68 | ->color('danger'), 69 | Stat::make('Total Labels', 5) 70 | ->description('7% increase') 71 | ->descriptionIcon('heroicon-m-arrow-trending-up') 72 | ->chart([15, 4, 10, 2, 12, 4, 12]) 73 | ->color('success'), 74 | ]; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | authorize('viewAny', Location::class); 17 | 18 | return LocationResource::collection(Location::all()); 19 | } 20 | 21 | public function store(Request $request) 22 | { 23 | $this->authorize('create', Location::class); 24 | 25 | $data = $request->validate([ 26 | 'name' => ['required'], 27 | 'description' => ['nullable'], 28 | 'parent_id' => ['nullable', 'integer'], 29 | 'is_active' => ['nullable', 'boolean'], 30 | ]); 31 | 32 | return new LocationResource(Location::create($data)); 33 | } 34 | 35 | public function show(Location $location) 36 | { 37 | $this->authorize('view', $location); 38 | 39 | return new LocationResource($location); 40 | } 41 | 42 | public function update(Request $request, Location $location) 43 | { 44 | $this->authorize('update', $location); 45 | 46 | $data = $request->validate([ 47 | 'name' => ['required'], 48 | 'description' => ['nullable'], 49 | 'parent_id' => ['nullable', 'integer'], 50 | 'is_active' => ['nullable', 'boolean'], 51 | ]); 52 | 53 | $location->update($data); 54 | 55 | return new LocationResource($location); 56 | } 57 | 58 | public function destroy(Location $location) 59 | { 60 | $this->authorize('delete', $location); 61 | 62 | $location->delete(); 63 | 64 | return response()->json(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Http/Middleware/ApplyTenantScopes.php: -------------------------------------------------------------------------------- 1 | $query->whereBelongsTo(Filament::getTenant()), 24 | ); 25 | Item::addGlobalScope( 26 | fn (Builder $query) => $query->whereBelongsTo(Filament::getTenant()), 27 | ); 28 | 29 | return $next($request); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/Http/Resources/ItemResource.php: -------------------------------------------------------------------------------- 1 | $this->created_at, 15 | 'updated_at' => $this->updated_at, 16 | 'id' => $this->id, 17 | 'ulid' => $this->ulid, 18 | 'name' => $this->name, 19 | 'description' => $this->description, 20 | 'import_ref' => $this->import_ref, 21 | 'notes' => $this->notes, 22 | 'quantity' => $this->quantity, 23 | 'insured' => $this->insured, 24 | 'archived' => $this->archived, 25 | 'asset_id' => $this->asset_id, 26 | 'serial_number' => $this->serial_number, 27 | 'model_number' => $this->model_number, 28 | 'manufacturer' => $this->manufacturer, 29 | 'lifetime_warranty' => $this->lifetime_warranty, 30 | 'warranty_expires' => $this->warranty_expires, 31 | 'warranty_details' => $this->warranty_details, 32 | 'purchase_time' => $this->purchase_time, 33 | 'purchase_from' => $this->purchase_from, 34 | 'purchase_price' => $this->purchase_price, 35 | 'sold_time' => $this->sold_time, 36 | 'sold_to' => $this->sold_to, 37 | 'sold_price' => $this->sold_price, 38 | 'sold_notes' => $this->sold_notes, 39 | 40 | 'location_id' => $this->location_id, 41 | 42 | 'location' => new LocationResource($this->whenLoaded('location')), 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/Http/Resources/LocationResource.php: -------------------------------------------------------------------------------- 1 | $this->created_at, 15 | 'updated_at' => $this->updated_at, 16 | 'id' => $this->id, 17 | 'name' => $this->name, 18 | 'description' => $this->description, 19 | 'parent_id' => $this->parent_id, 20 | 'is_active' => $this->is_active, 21 | ]; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Models/Location.php: -------------------------------------------------------------------------------- 1 | 'boolean', 24 | ]; 25 | 26 | protected static function boot() 27 | { 28 | parent::boot(); 29 | 30 | static::creating(function ($item) { 31 | if (auth()->check()) { 32 | $item->team_id = Filament::getTenant()?->id; 33 | } 34 | }); 35 | } 36 | 37 | public function parent(): \Illuminate\Database\Eloquent\Relations\BelongsTo 38 | { 39 | return $this->belongsTo(Location::class); 40 | } 41 | 42 | public function children(): \Illuminate\Database\Eloquent\Relations\HasMany 43 | { 44 | return $this->hasMany(Location::class, 'parent_id'); 45 | } 46 | 47 | public function team(): BelongsTo 48 | { 49 | return $this->belongsTo(Team::class); 50 | } 51 | 52 | public function items(): \Illuminate\Database\Eloquent\Relations\HasMany 53 | { 54 | return $this->hasMany(Item::class); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/Models/Membership.php: -------------------------------------------------------------------------------- 1 | 'array', 14 | ]; 15 | } 16 | 17 | protected static function boot(): void 18 | { 19 | parent::boot(); 20 | 21 | static::creating(function ($item) { 22 | if (auth()->check()) { 23 | $item->team_id = Filament::getTenant()?->id; 24 | } 25 | }); 26 | } 27 | 28 | public function team(): BelongsTo 29 | { 30 | return $this->belongsTo(Team::class); 31 | } 32 | 33 | public function items(): \Illuminate\Database\Eloquent\Relations\MorphToMany 34 | { 35 | return $this->morphedByMany(Item::class, 'taggable'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Models/Team.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected $fillable = [ 22 | 'name', 23 | 'personal_team', 24 | ]; 25 | 26 | /** 27 | * The event map for the model. 28 | * 29 | * @var array 30 | */ 31 | protected $dispatchesEvents = [ 32 | 'created' => TeamCreated::class, 33 | 'updated' => TeamUpdated::class, 34 | 'deleted' => TeamDeleted::class, 35 | ]; 36 | 37 | /** 38 | * Get the attributes that should be cast. 39 | * 40 | * @return array 41 | */ 42 | protected function casts(): array 43 | { 44 | return [ 45 | 'personal_team' => 'boolean', 46 | ]; 47 | } 48 | 49 | protected static function boot() 50 | { 51 | parent::boot(); 52 | 53 | static::creating(function ($team) { 54 | if (auth()->check()) { 55 | $team->user_id = auth()->id(); 56 | $team->personal_team = false; 57 | } 58 | }); 59 | } 60 | 61 | public function getCurrentTenantLabel(): string 62 | { 63 | return 'Current team'; 64 | } 65 | 66 | public function locations(): \Illuminate\Database\Eloquent\Relations\HasMany 67 | { 68 | return $this->hasMany(Location::class); 69 | } 70 | 71 | public function items(): \Illuminate\Database\Eloquent\Relations\HasMany 72 | { 73 | return $this->hasMany(Item::class); 74 | } 75 | 76 | public function tags(): \Illuminate\Database\Eloquent\Relations\HasMany 77 | { 78 | return $this->hasMany(Tag::class); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/Models/TeamInvitation.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | protected $fillable = [ 17 | 'email', 18 | 'role', 19 | ]; 20 | 21 | /** 22 | * Get the team that the invitation belongs to. 23 | */ 24 | public function team(): BelongsTo 25 | { 26 | return $this->belongsTo(Jetstream::teamModel()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | 32 | */ 33 | protected $fillable = [ 34 | 'name', 35 | 'email', 36 | 'password', 37 | ]; 38 | 39 | /** 40 | * The attributes that should be hidden for serialization. 41 | * 42 | * @var array 43 | */ 44 | protected $hidden = [ 45 | 'password', 46 | 'remember_token', 47 | 'two_factor_recovery_codes', 48 | 'two_factor_secret', 49 | ]; 50 | 51 | /** 52 | * The accessors to append to the model's array form. 53 | * 54 | * @var array 55 | */ 56 | protected $appends = [ 57 | 'profile_photo_url', 58 | ]; 59 | 60 | /** 61 | * Get the attributes that should be cast. 62 | * 63 | * @return array 64 | */ 65 | protected function casts(): array 66 | { 67 | return [ 68 | 'email_verified_at' => 'datetime', 69 | 'password' => 'hashed', 70 | ]; 71 | } 72 | 73 | public function canAccessPanel(Panel $panel): bool 74 | { 75 | return true; 76 | } 77 | 78 | public function getTenants(Panel $panel): Collection 79 | { 80 | return $this->allTeams(); 81 | } 82 | 83 | public function canAccessTenant(Model $tenant): bool 84 | { 85 | return $this->teams()->whereKey($tenant)->exists() || $this->ownsTeam($tenant); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/Policies/ItemPolicy.php: -------------------------------------------------------------------------------- 1 | belongsToTeam($team); 27 | } 28 | 29 | /** 30 | * Determine whether the user can create models. 31 | */ 32 | public function create(User $user): bool 33 | { 34 | return true; 35 | } 36 | 37 | /** 38 | * Determine whether the user can update the model. 39 | */ 40 | public function update(User $user, Team $team): bool 41 | { 42 | return $user->ownsTeam($team); 43 | } 44 | 45 | /** 46 | * Determine whether the user can add team members. 47 | */ 48 | public function addTeamMember(User $user, Team $team): bool 49 | { 50 | return $user->ownsTeam($team); 51 | } 52 | 53 | /** 54 | * Determine whether the user can update team member permissions. 55 | */ 56 | public function updateTeamMember(User $user, Team $team): bool 57 | { 58 | return $user->ownsTeam($team); 59 | } 60 | 61 | /** 62 | * Determine whether the user can remove team members. 63 | */ 64 | public function removeTeamMember(User $user, Team $team): bool 65 | { 66 | return $user->ownsTeam($team); 67 | } 68 | 69 | /** 70 | * Determine whether the user can delete the model. 71 | */ 72 | public function delete(User $user, Team $team): bool 73 | { 74 | return $user->ownsTeam($team); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->isProduction()); 39 | Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); 40 | 41 | Model::preventLazyLoading(! $this->app->isProduction()); 42 | 43 | Model::shouldBeStrict(! $this->app->isProduction()); 44 | 45 | Gate::define('viewPulse', function (User $user) { 46 | return true; // $user->hasRole('super-admin') || app()->isLocal(); 47 | }); 48 | 49 | if ($this->app->isProduction()) { 50 | Model::handleLazyLoadingViolationUsing(function ( 51 | $model, 52 | $relation 53 | ) { 54 | $class = get_class($model); 55 | 56 | info( 57 | "Attempted to lazy load [{$relation}] on model [{$class}]." 58 | ); 59 | }); 60 | } 61 | /*if ($this->app->isProduction()) { 62 | $this->app['request']->server->set('HTTPS', true); 63 | \URL::forceScheme('https'); 64 | }*/ 65 | 66 | if ($this->app->isLocal() && ! $this->app->runningInConsole()) { 67 | DB::listen(function (QueryExecuted $event) { 68 | if ($event->time > 250) { 69 | throw new QueryException( 70 | 'db', 71 | $event->sql, 72 | $event->bindings, 73 | new \Exception('Individual database query exceeded '.$event->time.'ms.') 74 | ); 75 | } 76 | }); 77 | 78 | DB::whenQueryingForLongerThan(2000, function (Connection $connection) { 79 | \Log::warning("Database queries exceeded 2 seconds on {$connection->getName()}"); 80 | }); 81 | 82 | } 83 | 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/Providers/FortifyServiceProvider.php: -------------------------------------------------------------------------------- 1 | input(Fortify::username())).'|'.$request->ip()); 38 | 39 | return Limit::perMinute(5)->by($throttleKey); 40 | }); 41 | 42 | RateLimiter::for('two-factor', function (Request $request) { 43 | return Limit::perMinute(5)->by($request->session()->get('login.id')); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Providers/JetstreamServiceProvider.php: -------------------------------------------------------------------------------- 1 | configurePermissions(); 31 | 32 | Jetstream::createTeamsUsing(CreateTeam::class); 33 | Jetstream::updateTeamNamesUsing(UpdateTeamName::class); 34 | Jetstream::addTeamMembersUsing(AddTeamMember::class); 35 | Jetstream::inviteTeamMembersUsing(InviteTeamMember::class); 36 | Jetstream::removeTeamMembersUsing(RemoveTeamMember::class); 37 | Jetstream::deleteTeamsUsing(DeleteTeam::class); 38 | Jetstream::deleteUsersUsing(DeleteUser::class); 39 | } 40 | 41 | /** 42 | * Configure the roles and permissions that are available within the application. 43 | */ 44 | protected function configurePermissions(): void 45 | { 46 | Jetstream::defaultApiTokenPermissions(['*']); 47 | 48 | /*Jetstream::role('admin', 'Administrator', [ 49 | 'create', 50 | 'read', 51 | 'update', 52 | 'delete', 53 | ])->description('Administrator users can perform any action.'); 54 | 55 | Jetstream::role('editor', 'Editor', [ 56 | 'read', 57 | 'create', 58 | 'update', 59 | ])->description('Editor users have the ability to read, create, and update.');*/ 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/View/Components/AppLayout.php: -------------------------------------------------------------------------------- 1 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 9 | web: __DIR__.'/../routes/web.php', 10 | api: __DIR__.'/../routes/api.php', 11 | commands: __DIR__.'/../routes/console.php', 12 | health: '/up', 13 | ) 14 | ->withMiddleware(function (Middleware $middleware) { 15 | $middleware->replace( 16 | \Illuminate\Http\Middleware\TrustProxies::class, 17 | \Monicahq\Cloudflare\Http\Middleware\TrustProxies::class 18 | ); 19 | 20 | }) 21 | ->withExceptions(function (Exceptions $exceptions) { 22 | // 23 | })->create(); 24 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | 'livewire', 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Jetstream Route Middleware 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Here you may specify which middleware Jetstream will assign to the routes 27 | | that it registers with the application. When necessary, you may modify 28 | | these middleware; however, this default value is usually sufficient. 29 | | 30 | */ 31 | 32 | 'middleware' => ['web'], 33 | 34 | 'auth_session' => AuthenticateSession::class, 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Jetstream Guard 39 | |-------------------------------------------------------------------------- 40 | | 41 | | Here you may specify the authentication guard Jetstream will use while 42 | | authenticating users. This value should correspond with one of your 43 | | guards that is already present in your "auth" configuration file. 44 | | 45 | */ 46 | 47 | 'guard' => 'sanctum', 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Features 52 | |-------------------------------------------------------------------------- 53 | | 54 | | Some of Jetstream's features are optional. You may disable the features 55 | | by removing them from this array. You're free to only remove some of 56 | | these features or you can even remove all of these if you need to. 57 | | 58 | */ 59 | 60 | 'features' => [ 61 | Features::termsAndPrivacyPolicy(), 62 | // Features::profilePhotos(), 63 | Features::api(), 64 | Features::teams(['invitations' => true]), 65 | Features::accountDeletion(), 66 | ], 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Profile Photo Disk 71 | |-------------------------------------------------------------------------- 72 | | 73 | | This configuration value determines the default disk that will be used 74 | | when storing profile photos for your application's users. Typically 75 | | this will be the "public" disk but you may adjust this if needed. 76 | | 77 | */ 78 | 79 | 'profile_photo_disk' => 'public', 80 | 81 | ]; 82 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'token' => env('POSTMARK_TOKEN'), 19 | ], 20 | 21 | 'ses' => [ 22 | 'key' => env('AWS_ACCESS_KEY_ID'), 23 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 24 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 25 | ], 26 | 27 | 'resend' => [ 28 | 'key' => env('RESEND_KEY'), 29 | ], 30 | 31 | 'slack' => [ 32 | 'notifications' => [ 33 | 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 34 | 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), 35 | ], 36 | ], 37 | 38 | ]; 39 | -------------------------------------------------------------------------------- /config/tags.php: -------------------------------------------------------------------------------- 1 | null, 10 | 11 | /* 12 | * The fully qualified class name of the tag model. 13 | */ 14 | 'tag_model' => \App\Models\Tag::class, 15 | 16 | /* 17 | * The name of the table associated with the taggable morph relation. 18 | */ 19 | 'taggable' => [ 20 | 'table_name' => 'taggables', 21 | 'morph_name' => 'taggable', 22 | 23 | /* 24 | * The fully qualified class name of the pivot model. 25 | */ 26 | 'class_name' => Illuminate\Database\Eloquent\Relations\MorphPivot::class, 27 | ], 28 | ]; 29 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /database/factories/ItemFactory.php: -------------------------------------------------------------------------------- 1 | Carbon::now(), 19 | 'updated_at' => Carbon::now(), 20 | 'ulid' => $this->faker->words(), 21 | 'name' => $this->faker->name(), 22 | 'description' => $this->faker->text(), 23 | 'import_ref' => $this->faker->word(), 24 | 'notes' => $this->faker->word(), 25 | 'quantity' => $this->faker->randomNumber(3), 26 | 'insured' => $this->faker->boolean(), 27 | 'archived' => $this->faker->boolean(), 28 | 'asset_id' => $this->faker->randomNumber(), 29 | 'serial_number' => $this->faker->word(), 30 | 'model_number' => $this->faker->word(), 31 | 'manufacturer' => $this->faker->word(), 32 | 'lifetime_warranty' => $this->faker->boolean(), 33 | 'warranty_expires' => Carbon::now(), 34 | 'warranty_details' => $this->faker->word(), 35 | 'purchase_time' => Carbon::now(), 36 | 'purchase_from' => $this->faker->word(), 37 | 'purchase_price' => $this->faker->randomFloat(), 38 | 'sold_time' => Carbon::now(), 39 | 'sold_to' => $this->faker->word(), 40 | 'sold_price' => $this->faker->randomFloat(), 41 | 'sold_notes' => $this->faker->word(), 42 | 43 | 'location_id' => Location::factory(), 44 | 45 | 'team_id' => Team::first()?->id ?? Team::factory()->create()->id, 46 | 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /database/factories/LocationFactory.php: -------------------------------------------------------------------------------- 1 | faker->boolean()) { 19 | $parent = Location::inRandomOrder()?->first() ?? Location::factory()->create(); 20 | } 21 | 22 | return [ 23 | 'created_at' => Carbon::now(), 24 | 'updated_at' => Carbon::now(), 25 | 'name' => $this->faker->name(), 26 | 'description' => $this->faker->text(), 27 | 'parent_id' => $parent?->id, 28 | 'is_active' => $this->faker->boolean(), 29 | 'team_id' => Team::first()?->id ?? Team::factory()->create()->id, 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /database/factories/TagFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->unique()->word, 17 | 'team_id' => Team::first()?->id ?? Team::factory()->create()->id, 18 | ]; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /database/factories/TeamFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class TeamFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition(): array 19 | { 20 | return [ 21 | 'name' => $this->faker->unique()->company(), 22 | 'user_id' => User::factory(), 23 | 'personal_team' => true, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class UserFactory extends Factory 16 | { 17 | /** 18 | * The current password being used by the factory. 19 | */ 20 | protected static ?string $password; 21 | 22 | /** 23 | * Define the model's default state. 24 | * 25 | * @return array 26 | */ 27 | public function definition(): array 28 | { 29 | return [ 30 | 'name' => fake()->name(), 31 | 'email' => fake()->unique()->safeEmail(), 32 | 'email_verified_at' => now(), 33 | 'password' => static::$password ??= Hash::make('password'), 34 | 'two_factor_secret' => null, 35 | 'two_factor_recovery_codes' => null, 36 | 'remember_token' => Str::random(10), 37 | 'profile_photo_path' => null, 38 | 'current_team_id' => null, 39 | ]; 40 | } 41 | 42 | /** 43 | * Indicate that the model's email address should be unverified. 44 | */ 45 | public function unverified(): static 46 | { 47 | return $this->state(fn (array $attributes) => [ 48 | 'email_verified_at' => null, 49 | ]); 50 | } 51 | 52 | /** 53 | * Indicate that the user should have a personal team. 54 | */ 55 | public function withPersonalTeam(?callable $callback = null): static 56 | { 57 | if (! Features::hasTeamFeatures()) { 58 | return $this->state([]); 59 | } 60 | 61 | return $this->has( 62 | Team::factory() 63 | ->state(fn (array $attributes, User $user) => [ 64 | 'name' => $user->name.'\'s Team', 65 | 'user_id' => $user->id, 66 | 'personal_team' => true, 67 | ]) 68 | ->when(is_callable($callback), $callback), 69 | 'ownedTeams' 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('name'); 17 | $table->string('email')->unique(); 18 | $table->timestamp('email_verified_at')->nullable(); 19 | $table->string('password'); 20 | $table->rememberToken(); 21 | $table->foreignId('current_team_id')->nullable(); 22 | $table->string('profile_photo_path', 2048)->nullable(); 23 | $table->timestamps(); 24 | }); 25 | 26 | Schema::create('password_reset_tokens', function (Blueprint $table) { 27 | $table->string('email')->primary(); 28 | $table->string('token'); 29 | $table->timestamp('created_at')->nullable(); 30 | }); 31 | 32 | Schema::create('sessions', function (Blueprint $table) { 33 | $table->string('id')->primary(); 34 | $table->foreignId('user_id')->nullable()->index(); 35 | $table->string('ip_address', 45)->nullable(); 36 | $table->text('user_agent')->nullable(); 37 | $table->longText('payload'); 38 | $table->integer('last_activity')->index(); 39 | }); 40 | } 41 | 42 | /** 43 | * Reverse the migrations. 44 | */ 45 | public function down(): void 46 | { 47 | Schema::dropIfExists('users'); 48 | Schema::dropIfExists('password_reset_tokens'); 49 | Schema::dropIfExists('sessions'); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000001_create_cache_table.php: -------------------------------------------------------------------------------- 1 | string('key')->primary(); 16 | $table->mediumText('value'); 17 | $table->integer('expiration'); 18 | }); 19 | 20 | Schema::create('cache_locks', function (Blueprint $table) { 21 | $table->string('key')->primary(); 22 | $table->string('owner'); 23 | $table->integer('expiration'); 24 | }); 25 | } 26 | 27 | /** 28 | * Reverse the migrations. 29 | */ 30 | public function down(): void 31 | { 32 | Schema::dropIfExists('cache'); 33 | Schema::dropIfExists('cache_locks'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/0001_01_01_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('queue')->index(); 17 | $table->longText('payload'); 18 | $table->unsignedTinyInteger('attempts'); 19 | $table->unsignedInteger('reserved_at')->nullable(); 20 | $table->unsignedInteger('available_at'); 21 | $table->unsignedInteger('created_at'); 22 | }); 23 | 24 | Schema::create('job_batches', function (Blueprint $table) { 25 | $table->string('id')->primary(); 26 | $table->string('name'); 27 | $table->integer('total_jobs'); 28 | $table->integer('pending_jobs'); 29 | $table->integer('failed_jobs'); 30 | $table->longText('failed_job_ids'); 31 | $table->mediumText('options')->nullable(); 32 | $table->integer('cancelled_at')->nullable(); 33 | $table->integer('created_at'); 34 | $table->integer('finished_at')->nullable(); 35 | }); 36 | 37 | Schema::create('failed_jobs', function (Blueprint $table) { 38 | $table->id(); 39 | $table->string('uuid')->unique(); 40 | $table->text('connection'); 41 | $table->text('queue'); 42 | $table->longText('payload'); 43 | $table->longText('exception'); 44 | $table->timestamp('failed_at')->useCurrent(); 45 | }); 46 | } 47 | 48 | /** 49 | * Reverse the migrations. 50 | */ 51 | public function down(): void 52 | { 53 | Schema::dropIfExists('jobs'); 54 | Schema::dropIfExists('job_batches'); 55 | Schema::dropIfExists('failed_jobs'); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /database/migrations/2024_06_16_193714_add_two_factor_columns_to_users_table.php: -------------------------------------------------------------------------------- 1 | text('two_factor_secret') 17 | ->after('password') 18 | ->nullable(); 19 | 20 | $table->text('two_factor_recovery_codes') 21 | ->after('two_factor_secret') 22 | ->nullable(); 23 | 24 | if (Fortify::confirmsTwoFactorAuthentication()) { 25 | $table->timestamp('two_factor_confirmed_at') 26 | ->after('two_factor_recovery_codes') 27 | ->nullable(); 28 | } 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | */ 35 | public function down(): void 36 | { 37 | Schema::table('users', function (Blueprint $table) { 38 | $table->dropColumn(array_merge([ 39 | 'two_factor_secret', 40 | 'two_factor_recovery_codes', 41 | ], Fortify::confirmsTwoFactorAuthentication() ? [ 42 | 'two_factor_confirmed_at', 43 | ] : [])); 44 | }); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /database/migrations/2024_06_16_194657_create_personal_access_tokens_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('tokenable'); 17 | $table->string('name'); 18 | $table->string('token', 64)->unique(); 19 | $table->text('abilities')->nullable(); 20 | $table->timestamp('last_used_at')->nullable(); 21 | $table->timestamp('expires_at')->nullable(); 22 | $table->timestamps(); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | */ 29 | public function down(): void 30 | { 31 | Schema::dropIfExists('personal_access_tokens'); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /database/migrations/2024_06_16_194658_create_teams_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('user_id')->index(); 17 | $table->string('name'); 18 | $table->boolean('personal_team'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::dropIfExists('teams'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2024_06_16_194659_create_team_user_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('team_id'); 17 | $table->foreignId('user_id'); 18 | $table->string('role')->nullable(); 19 | $table->timestamps(); 20 | 21 | $table->unique(['team_id', 'user_id']); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('team_user'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2024_06_16_194700_create_team_invitations_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->foreignId('team_id')->constrained()->cascadeOnDelete(); 17 | $table->string('email'); 18 | $table->string('role')->nullable(); 19 | $table->timestamps(); 20 | 21 | $table->unique(['team_id', 'email']); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('team_invitations'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_081330_create_locations_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->string('name'); 14 | $table->text('description')->nullable(); 15 | $table->unsignedBigInteger('parent_id')->nullable(); 16 | $table->boolean('is_active')->nullable(); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | public function down(): void 22 | { 23 | Schema::dropIfExists('locations'); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_200440_create_items_table.php: -------------------------------------------------------------------------------- 1 | id(); 13 | $table->ulid('ulid'); 14 | $table->text('name')->index(); 15 | $table->text('description')->nullable(); 16 | $table->text('import_ref')->nullable(); 17 | $table->text('notes')->nullable(); 18 | $table->integer('quantity')->default(1); 19 | $table->boolean('insured')->nullable()->default(false); 20 | $table->boolean('archived')->nullable()->default(false); 21 | $table->unsignedBigInteger('asset_id')->nullable()->index(); 22 | $table->string('serial_number')->nullable()->index(); 23 | $table->string('model_number')->nullable()->index(); 24 | $table->string('manufacturer')->nullable(); 25 | $table->boolean('lifetime_warranty')->nullable()->default(false); 26 | $table->dateTime('warranty_expires')->nullable(); 27 | $table->text('warranty_details')->nullable(); 28 | $table->dateTime('purchase_time')->nullable(); 29 | $table->string('purchase_from')->nullable(); 30 | $table->double('purchase_price')->nullable(); 31 | $table->dateTime('sold_time')->nullable(); 32 | $table->string('sold_to')->nullable(); 33 | $table->double('sold_price')->nullable(); 34 | $table->text('sold_notes')->nullable(); 35 | $table->foreignId('location_id'); 36 | $table->softDeletes(); 37 | $table->timestamps(); 38 | }); 39 | } 40 | 41 | public function down(): void 42 | { 43 | Schema::dropIfExists('items'); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_211601_create_notifications_table.php: -------------------------------------------------------------------------------- 1 | uuid('id')->primary(); 16 | $table->string('type'); 17 | $table->morphs('notifiable'); 18 | $table->text('data'); 19 | $table->timestamp('read_at')->nullable(); 20 | $table->timestamps(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('notifications'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_211615_create_imports_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->timestamp('completed_at')->nullable(); 17 | $table->string('file_name'); 18 | $table->string('file_path'); 19 | $table->string('importer'); 20 | $table->unsignedInteger('processed_rows')->default(0); 21 | $table->unsignedInteger('total_rows'); 22 | $table->unsignedInteger('successful_rows')->default(0); 23 | $table->foreignId('user_id')->constrained()->cascadeOnDelete(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('imports'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_211616_create_exports_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->timestamp('completed_at')->nullable(); 17 | $table->string('file_disk'); 18 | $table->string('file_name')->nullable(); 19 | $table->string('exporter'); 20 | $table->unsignedInteger('processed_rows')->default(0); 21 | $table->unsignedInteger('total_rows'); 22 | $table->unsignedInteger('successful_rows')->default(0); 23 | $table->foreignId('user_id')->constrained()->cascadeOnDelete(); 24 | $table->timestamps(); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('exports'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/migrations/2024_06_17_211617_create_failed_import_rows_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->json('data'); 17 | $table->foreignId('import_id')->constrained()->cascadeOnDelete(); 18 | $table->text('validation_error')->nullable(); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | */ 26 | public function down(): void 27 | { 28 | Schema::dropIfExists('failed_import_rows'); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /database/migrations/2024_06_18_204247_add_team_id_to_items_table.php: -------------------------------------------------------------------------------- 1 | truncate(); 13 | 14 | Schema::table('items', function (Blueprint $table) { 15 | $table->foreignId('team_id')->constrained()->cascadeOnDelete(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::table('items', function (Blueprint $table) { 22 | $table->dropConstrainedForeignId('team_id'); 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /database/migrations/2024_06_18_204347_add_team_id_to_locations_table.php: -------------------------------------------------------------------------------- 1 | truncate(); 12 | 13 | Schema::table('locations', function (Blueprint $table) { 14 | $table->foreignId('team_id')->constrained()->cascadeOnDelete(); 15 | }); 16 | } 17 | 18 | public function down(): void 19 | { 20 | Schema::table('locations', function (Blueprint $table) { 21 | $table->dropConstrainedForeignId('team_id'); 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /database/migrations/2024_06_21_151152_add_custom_fields_columns_to_items_table.php: -------------------------------------------------------------------------------- 1 | json('fields')->nullable(); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('items', function (Blueprint $table) { 19 | $table->dropColumn('fields'); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /database/migrations/2024_06_21_174327_create_tag_tables.php: -------------------------------------------------------------------------------- 1 | id(); 13 | 14 | $table->json('name'); 15 | $table->json('slug'); 16 | $table->string('type')->nullable(); 17 | $table->integer('order_column')->nullable(); 18 | 19 | $table->timestamps(); 20 | }); 21 | 22 | Schema::create('taggables', function (Blueprint $table) { 23 | $table->foreignId('tag_id')->constrained()->cascadeOnDelete(); 24 | 25 | $table->morphs('taggable'); 26 | 27 | $table->unique(['tag_id', 'taggable_id', 'taggable_type']); 28 | }); 29 | } 30 | 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('taggables'); 34 | Schema::dropIfExists('tags'); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /database/migrations/2024_06_21_185152_add_team_id_to_tags_table.php: -------------------------------------------------------------------------------- 1 | foreignId('team_id')->constrained()->cascadeOnDelete(); 13 | }); 14 | } 15 | 16 | public function down(): void 17 | { 18 | Schema::table('tags', function (Blueprint $table) { 19 | $table->dropForeign(['team_id']); 20 | $table->dropColumn('team_id'); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /database/migrations/2024_06_24_202341_change_schema_for_items_table.php: -------------------------------------------------------------------------------- 1 | dropIndex('items_name_index'); 14 | $table->text('name')->index()->nullable()->change(); 15 | $table->foreignId('location_id')->nullable()->change(); 16 | }); 17 | } 18 | 19 | public function down(): void 20 | { 21 | Schema::table('items', function (Blueprint $table) { 22 | $table->dropIndex('items_name_index'); 23 | $table->text('name')->index()->change(); 24 | $table->foreignId('location_id')->change(); 25 | }); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | withPersonalTeam()->create(); 23 | 24 | User::factory()->withPersonalTeam()->create([ 25 | 'name' => 'Demo User', 26 | 'email' => 'demo@example.com', 27 | 'password' => 'demo@example.com', 28 | ]); 29 | 30 | $tags = collect([ 31 | 'Appliances', 32 | 'IOT', 33 | 'Electronics', 34 | 'Servers', 35 | 'General', 36 | 'Important', 37 | ])->map(fn ($tag) => Tag::factory()->create([ 38 | 'name' => $tag, 39 | ])); 40 | 41 | $locations = collect([ 42 | 'Living Room', 43 | 'Garage', 44 | 'Kitchen', 45 | 'Bedroom', 46 | 'Bathroom', 47 | 'Office', 48 | 'Attic', 49 | 'Basement', 50 | ])->map(fn ($location) => Location::factory()->create([ 51 | 'name' => $location, 52 | ])); 53 | 54 | $items = Item::factory(100)->create([ 55 | // 'tags' => $tags->random(random_int(1,3)), 56 | 'location_id' => $locations->random(), 57 | ]); 58 | $items->each(function ($item) use ($tags) { 59 | 60 | $tagCount = mt_rand(1, 3); 61 | $tags->random($tagCount)->each(function ($tag) use ($item) { 62 | $item->tags()->attach($tag); 63 | }); 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docker-compose.wip.yml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | image: serversideup/php:8.3-fpm-nginx 4 | ports: 5 | - '9000:8000' 6 | - '8080:8080' 7 | - '8443:8443' 8 | environment: 9 | PHP_FPM_POOL_NAME: "easy-inventory_php" 10 | PHP_OPCACHE_ENABLE: 1 11 | SSL_MODE: "mix" 12 | AUTORUN_ENABLED: "false" 13 | volumes: 14 | - .:/var/www/html 15 | # networks: 16 | # - sail 17 | # depends_on: 18 | # - mysql 19 | # mysql: 20 | # image: 'mysql/mysql-server:8.0' 21 | # ports: 22 | # - '${FORWARD_DB_PORT:-3306}:3306' 23 | # environment: 24 | # MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}' 25 | # MYSQL_ROOT_HOST: '%' 26 | # MYSQL_DATABASE: '${DB_DATABASE}' 27 | # MYSQL_USER: '${DB_USERNAME}' 28 | # MYSQL_PASSWORD: '${DB_PASSWORD}' 29 | # MYSQL_ALLOW_EMPTY_PASSWORD: 1 30 | # volumes: 31 | # - 'sail-mysql:/var/lib/mysql' 32 | # networks: 33 | # - sail 34 | # healthcheck: 35 | # test: ['CMD', 'mysqladmin', 'ping', '-p${DB_PASSWORD}'] 36 | # retries: 3 37 | # timeout: 5s 38 | # 39 | # queue: 40 | # image: serversideup/php:8.3-fpm-nginx 41 | # command: ["php", "/var/www/html/artisan", "queue:work", "--tries=3"] 42 | # environment: 43 | # PHP_FPM_POOL_NAME: "my-app_queue" 44 | # volumes: 45 | # - .:/var/www/html 46 | # 47 | # task: 48 | # image: serversideup/php:8.3-fpm-nginx 49 | # command: ["php", "/var/www/html/artisan", "schedule:work"] 50 | # environment: 51 | # PHP_FPM_POOL_NAME: "my-app_task" 52 | # volumes: 53 | # - .:/var/www/html 54 | 55 | 56 | # 57 | #networks: 58 | # sail: 59 | # driver: bridge 60 | #volumes: 61 | # sail-mysql: 62 | # driver: local 63 | 64 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | frankenphp: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "4431:443" 8 | - "4431:443/udp" 9 | - "801:80" 10 | volumes: 11 | - .:/app 12 | restart: unless-stopped 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build" 7 | }, 8 | "devDependencies": { 9 | "@tailwindcss/forms": "^0.5.7", 10 | "@tailwindcss/typography": "^0.5.10", 11 | "autoprefixer": "^10.4.16", 12 | "axios": "^1.6.4", 13 | "laravel-vite-plugin": "^1.0", 14 | "postcss": "^8.4.32", 15 | "tailwindcss": "^3.4.0", 16 | "vite": "^5.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | 16 | 17 | app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "laravel" 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/css/filament/support/support.css: -------------------------------------------------------------------------------- 1 | .fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media (min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media (min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{background-color:#333;border-radius:4px;color:#fff;font-size:14px;line-height:1.4;outline:0;position:relative;transition-property:transform,visibility,opacity;white-space:normal}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{border-top-color:initial;border-width:8px 8px 0;bottom:-7px;left:0;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:initial;border-width:0 8px 8px;left:0;top:-7px;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-left-color:initial;border-width:8px 0 8px 8px;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{border-right-color:initial;border-width:8px 8px 8px 0;left:-7px;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{color:#333;height:16px;width:16px}.tippy-arrow:before{border-color:transparent;border-style:solid;content:"";position:absolute}.tippy-content{padding:5px 9px;position:relative;z-index:1}.tippy-box[data-theme~=light]{background-color:#fff;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;color:#26323d}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3} -------------------------------------------------------------------------------- /public/css/pxlrbt/filament-spotlight/spotlight-css.css: -------------------------------------------------------------------------------- 1 | .right-5{right:1.25rem}.ml-1{margin-left:.25rem}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity))}.pt-16{padding-top:4rem}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity))}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.duration-150,.transition-opacity{transition-duration:.15s}[x-cloak=""]{display:none!important}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity))}.focus\:border-0:focus{border-width:0}.focus\:border-transparent:focus{border-color:transparent}.focus\:shadow-none:focus{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@media (min-width:640px){.sm\:pt-24{padding-top:6rem}} -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flatroy/stupid-inventory-laravel/9a87feb2a45e4de002fb0d2fa8dce6b1f66b568b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /public/js/app/custom-script.js: -------------------------------------------------------------------------------- 1 | const original = window.history.replaceState; 2 | let timer = Date.now(); 3 | 4 | let timeout = null; 5 | let lastArgs = null; 6 | 7 | window.history.replaceState = function (...args) { 8 | const time = Date.now(); 9 | 10 | if (time - timer < 300) { 11 | lastArgs = args; 12 | 13 | if (timeout) { 14 | clearTimeout(timeout); 15 | } 16 | 17 | timeout = setTimeout(() => { 18 | original.apply(this, lastArgs); 19 | 20 | timeout = null; 21 | lastArgs = null; 22 | }, 100); 23 | 24 | return; 25 | } 26 | 27 | timer = time; 28 | 29 | original.apply(this, args); 30 | }; 31 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/key-value.js: -------------------------------------------------------------------------------- 1 | function r({state:o}){return{state:o,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows);this.rows=[];let s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.$nextTick(()=>{this.rows=e,this.updateState()})},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default}; 2 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/tags-input.js: -------------------------------------------------------------------------------- 1 | function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default}; 2 | -------------------------------------------------------------------------------- /public/js/filament/forms/components/textarea.js: -------------------------------------------------------------------------------- 1 | function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init:function(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight:function(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize:function(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver:function(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default}; 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /public/welcome-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flatroy/stupid-inventory-laravel/9a87feb2a45e4de002fb0d2fa8dce6b1f66b568b/public/welcome-hero.png -------------------------------------------------------------------------------- /public/welcome-person.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Flatroy/stupid-inventory-laravel/9a87feb2a45e4de002fb0d2fa8dce6b1f66b568b/public/welcome-person.jpg -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | [x-cloak] { 6 | display: none; 7 | } 8 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | import './bootstrap'; 2 | -------------------------------------------------------------------------------- /resources/js/bootstrap.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | window.axios = axios; 3 | 4 | window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 5 | -------------------------------------------------------------------------------- /resources/js/custom.filament.js: -------------------------------------------------------------------------------- 1 | const original = window.history.replaceState; 2 | let timer = Date.now(); 3 | 4 | let timeout = null; 5 | let lastArgs = null; 6 | 7 | window.history.replaceState = function (...args) { 8 | const time = Date.now(); 9 | 10 | if (time - timer < 300) { 11 | lastArgs = args; 12 | 13 | if (timeout) { 14 | clearTimeout(timeout); 15 | } 16 | 17 | timeout = setTimeout(() => { 18 | original.apply(this, lastArgs); 19 | 20 | timeout = null; 21 | lastArgs = null; 22 | }, 100); 23 | 24 | return; 25 | } 26 | 27 | timer = time; 28 | 29 | original.apply(this, args); 30 | }; 31 | -------------------------------------------------------------------------------- /resources/markdown/policy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Edit this file to define the privacy policy for your application. 4 | -------------------------------------------------------------------------------- /resources/markdown/terms.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | Edit this file to define the terms of service for your application. 4 | -------------------------------------------------------------------------------- /resources/views/api/index.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('API Tokens') }} 5 |

6 |
7 | 8 |
9 |
10 | @livewire('api.api-token-manager') 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/views/auth/confirm-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} 9 |
10 | 11 | 12 | 13 |
14 | @csrf 15 | 16 |
17 | 18 | 19 |
20 | 21 |
22 | 23 | {{ __('Confirm') }} 24 | 25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /resources/views/auth/forgot-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} 9 |
10 | 11 | @session('status') 12 |
13 | {{ $value }} 14 |
15 | @endsession 16 | 17 | 18 | 19 |
20 | @csrf 21 | 22 |
23 | 24 | 25 |
26 | 27 |
28 | 29 | {{ __('Email Password Reset Link') }} 30 | 31 |
32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /resources/views/auth/login.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | @session('status') 10 |
11 | {{ $value }} 12 |
13 | @endsession 14 | 15 |
16 | @csrf 17 | 18 |
19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 |
27 | 28 |
29 | 33 |
34 | 35 |
36 | @if (Route::has('password.request')) 37 | 38 | {{ __('Forgot your password?') }} 39 | 40 | @endif 41 | 42 | 43 | {{ __('Log in') }} 44 | 45 |
46 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /resources/views/auth/reset-password.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | @csrf 11 | 12 | 13 | 14 |
15 | 16 | 17 |
18 | 19 |
20 | 21 | 22 |
23 | 24 |
25 | 26 | 27 |
28 | 29 |
30 | 31 | {{ __('Reset Password') }} 32 | 33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /resources/views/auth/two-factor-challenge.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 | {{ __('Please confirm access to your account by entering the authentication code provided by your authenticator application.') }} 10 |
11 | 12 |
13 | {{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }} 14 |
15 | 16 | 17 | 18 |
19 | @csrf 20 | 21 |
22 | 23 | 24 |
25 | 26 |
27 | 28 | 29 |
30 | 31 |
32 | 40 | 41 | 50 | 51 | 52 | {{ __('Log in') }} 53 | 54 |
55 |
56 |
57 |
58 |
59 | -------------------------------------------------------------------------------- /resources/views/auth/verify-email.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | {{ __('Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} 9 |
10 | 11 | @if (session('status') == 'verification-link-sent') 12 |
13 | {{ __('A new verification link has been sent to the email address you provided in your profile settings.') }} 14 |
15 | @endif 16 | 17 |
18 |
19 | @csrf 20 | 21 |
22 | 23 | {{ __('Resend Verification Email') }} 24 | 25 |
26 |
27 | 28 |
29 | 33 | {{ __('Edit Profile') }} 34 | 35 |
36 | @csrf 37 | 38 | 41 |
42 |
43 |
44 |
45 |
46 | -------------------------------------------------------------------------------- /resources/views/components/action-message.blade.php: -------------------------------------------------------------------------------- 1 | @props(['on']) 2 | 3 |
merge(['class' => 'text-sm text-gray-600']) }}> 9 | {{ $slot->isEmpty() ? 'Saved.' : $slot }} 10 |
11 | -------------------------------------------------------------------------------- /resources/views/components/action-section.blade.php: -------------------------------------------------------------------------------- 1 |
merge(['class' => 'md:grid md:grid-cols-3 md:gap-6']) }}> 2 | 3 | {{ $title }} 4 | {{ $description }} 5 | 6 | 7 |
8 |
9 | {{ $content }} 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /resources/views/components/application-mark.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/views/components/authentication-card-logo.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /resources/views/components/authentication-card.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{ $logo }} 4 |
5 | 6 |
7 | {{ $slot }} 8 |
9 |
10 | -------------------------------------------------------------------------------- /resources/views/components/button.blade.php: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /resources/views/components/checkbox.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500']) !!}> 2 | -------------------------------------------------------------------------------- /resources/views/components/confirmation-modal.blade.php: -------------------------------------------------------------------------------- 1 | @props(['id' => null, 'maxWidth' => null]) 2 | 3 | 4 |
5 |
6 |
7 | 8 | 9 | 10 |
11 | 12 |
13 |

14 | {{ $title }} 15 |

16 | 17 |
18 | {{ $content }} 19 |
20 |
21 |
22 |
23 | 24 |
25 | {{ $footer }} 26 |
27 |
28 | -------------------------------------------------------------------------------- /resources/views/components/confirms-password.blade.php: -------------------------------------------------------------------------------- 1 | @props(['title' => __('Confirm Password'), 'content' => __('For your security, please confirm your password to continue.'), 'button' => __('Confirm')]) 2 | 3 | @php 4 | $confirmableId = md5($attributes->wire('then')); 5 | @endphp 6 | 7 | wire('then') }} 9 | x-data 10 | x-ref="span" 11 | x-on:click="$wire.startConfirmingPassword('{{ $confirmableId }}')" 12 | x-on:password-confirmed.window="setTimeout(() => $event.detail.id === '{{ $confirmableId }}' && $refs.span.dispatchEvent(new CustomEvent('then', { bubbles: false })), 250);" 13 | > 14 | {{ $slot }} 15 | 16 | 17 | @once 18 | 19 | 20 | {{ $title }} 21 | 22 | 23 | 24 | {{ $content }} 25 | 26 |
27 | 31 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | {{ __('Cancel') }} 39 | 40 | 41 | 42 | {{ $button }} 43 | 44 | 45 |
46 | @endonce 47 | -------------------------------------------------------------------------------- /resources/views/components/danger-button.blade.php: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /resources/views/components/dialog-modal.blade.php: -------------------------------------------------------------------------------- 1 | @props(['id' => null, 'maxWidth' => null]) 2 | 3 | 4 |
5 |
6 | {{ $title }} 7 |
8 | 9 |
10 | {{ $content }} 11 |
12 |
13 | 14 |
15 | {{ $footer }} 16 |
17 |
18 | -------------------------------------------------------------------------------- /resources/views/components/dropdown-link.blade.php: -------------------------------------------------------------------------------- 1 | merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out']) }}>{{ $slot }} 2 | -------------------------------------------------------------------------------- /resources/views/components/dropdown.blade.php: -------------------------------------------------------------------------------- 1 | @props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white', 'dropdownClasses' => '']) 2 | 3 | @php 4 | switch ($align) { 5 | case 'left': 6 | $alignmentClasses = 'ltr:origin-top-left rtl:origin-top-right start-0'; 7 | break; 8 | case 'top': 9 | $alignmentClasses = 'origin-top'; 10 | break; 11 | case 'none': 12 | case 'false': 13 | $alignmentClasses = ''; 14 | break; 15 | case 'right': 16 | default: 17 | $alignmentClasses = 'ltr:origin-top-right rtl:origin-top-left end-0'; 18 | break; 19 | } 20 | 21 | switch ($width) { 22 | case '48': 23 | $width = 'w-48'; 24 | break; 25 | } 26 | @endphp 27 | 28 |
29 |
30 | {{ $trigger }} 31 |
32 | 33 | 47 |
48 | -------------------------------------------------------------------------------- /resources/views/components/form-section.blade.php: -------------------------------------------------------------------------------- 1 | @props(['submit']) 2 | 3 |
merge(['class' => 'md:grid md:grid-cols-3 md:gap-6']) }}> 4 | 5 | {{ $title }} 6 | {{ $description }} 7 | 8 | 9 |
10 |
11 |
12 |
13 | {{ $form }} 14 |
15 |
16 | 17 | @if (isset($actions)) 18 |
19 | {{ $actions }} 20 |
21 | @endif 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /resources/views/components/input-error.blade.php: -------------------------------------------------------------------------------- 1 | @props(['for']) 2 | 3 | @error($for) 4 |

merge(['class' => 'text-sm text-red-600']) }}>{{ $message }}

5 | @enderror 6 | -------------------------------------------------------------------------------- /resources/views/components/input.blade.php: -------------------------------------------------------------------------------- 1 | @props(['disabled' => false]) 2 | 3 | merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) !!}> 4 | -------------------------------------------------------------------------------- /resources/views/components/label.blade.php: -------------------------------------------------------------------------------- 1 | @props(['value']) 2 | 3 | 6 | -------------------------------------------------------------------------------- /resources/views/components/modal.blade.php: -------------------------------------------------------------------------------- 1 | @props(['id', 'maxWidth']) 2 | 3 | @php 4 | $id = $id ?? md5($attributes->wire('model')); 5 | 6 | $maxWidth = [ 7 | 'sm' => 'sm:max-w-sm', 8 | 'md' => 'sm:max-w-md', 9 | 'lg' => 'sm:max-w-lg', 10 | 'xl' => 'sm:max-w-xl', 11 | '2xl' => 'sm:max-w-2xl', 12 | ][$maxWidth ?? '2xl']; 13 | @endphp 14 | 15 | 44 | -------------------------------------------------------------------------------- /resources/views/components/nav-link.blade.php: -------------------------------------------------------------------------------- 1 | @props(['active']) 2 | 3 | @php 4 | $classes = ($active ?? false) 5 | ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' 6 | : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'; 7 | @endphp 8 | 9 | merge(['class' => $classes]) }}> 10 | {{ $slot }} 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/responsive-nav-link.blade.php: -------------------------------------------------------------------------------- 1 | @props(['active']) 2 | 3 | @php 4 | $classes = ($active ?? false) 5 | ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out' 6 | : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'; 7 | @endphp 8 | 9 | merge(['class' => $classes]) }}> 10 | {{ $slot }} 11 | 12 | -------------------------------------------------------------------------------- /resources/views/components/secondary-button.blade.php: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /resources/views/components/section-border.blade.php: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /resources/views/components/section-title.blade.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |

{{ $title }}

4 | 5 |

6 | {{ $description }} 7 |

8 |
9 | 10 |
11 | {{ $aside ?? '' }} 12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/views/components/switchable-team.blade.php: -------------------------------------------------------------------------------- 1 | @props(['team', 'component' => 'dropdown-link']) 2 | 3 |
4 | @method('PUT') 5 | @csrf 6 | 7 | 8 | 9 | 10 | 11 |
12 | @if (Auth::user()->isCurrentTeam($team)) 13 | 14 | 15 | 16 | @endif 17 | 18 |
{{ $team->name }}
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /resources/views/components/validation-errors.blade.php: -------------------------------------------------------------------------------- 1 | @if ($errors->any()) 2 |
3 |
{{ __('Whoops! Something went wrong.') }}
4 | 5 |
    6 | @foreach ($errors->all() as $error) 7 |
  • {{ $error }}
  • 8 | @endforeach 9 |
10 |
11 | @endif 12 | -------------------------------------------------------------------------------- /resources/views/dashboard.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Dashboard') }} 5 |

6 |
7 | 8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /resources/views/emails/team-invitation.blade.php: -------------------------------------------------------------------------------- 1 | @component('mail::message') 2 | {{ __('You have been invited to join the :team team!', ['team' => $invitation->team->name]) }} 3 | 4 | @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration())) 5 | {{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }} 6 | 7 | @component('mail::button', ['url' => route('register')]) 8 | {{ __('Create Account') }} 9 | @endcomponent 10 | 11 | {{ __('If you already have an account, you may accept this invitation by clicking the button below:') }} 12 | 13 | @else 14 | {{ __('You may accept this invitation by clicking the button below:') }} 15 | @endif 16 | 17 | 18 | @component('mail::button', ['url' => $acceptUrl]) 19 | {{ __('Accept Invitation') }} 20 | @endcomponent 21 | 22 | {{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }} 23 | @endcomponent 24 | -------------------------------------------------------------------------------- /resources/views/filament/widgets/labels.blade.php: -------------------------------------------------------------------------------- 1 | 3 | 7 | 8 | {{ $getRecord()->name }} {{ $getRecord()->items_count ? '('.$getRecord()->items_count.')': '' }} 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/views/layouts/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ config('app.name', 'Laravel') }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | @vite(['resources/css/app.css', 'resources/js/app.js']) 16 | 17 | 18 | @livewireStyles 19 | 20 | 21 | 22 | 23 |
24 | @livewire('navigation-menu') 25 | 26 | 27 | @if (isset($header)) 28 |
29 |
30 | {{ $header }} 31 |
32 |
33 | @endif 34 | 35 | 36 |
37 | {{ $slot }} 38 |
39 |
40 | 41 | @stack('modals') 42 | 43 | @livewireScripts 44 | 45 | 46 | -------------------------------------------------------------------------------- /resources/views/layouts/guest.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ config('app.name', 'Laravel') }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | @vite(['resources/css/app.css', 'resources/js/app.js']) 16 | 17 | 18 | @livewireStyles 19 | 20 | 21 |
22 | {{ $slot }} 23 |
24 | 25 | @livewireScripts 26 | 27 | 28 | -------------------------------------------------------------------------------- /resources/views/policy.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
7 | 8 |
9 | {!! $policy !!} 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/views/profile/delete-user-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Delete Account') }} 4 | 5 | 6 | 7 | {{ __('Permanently delete your account.') }} 8 | 9 | 10 | 11 |
12 | {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} 13 |
14 | 15 |
16 | 17 | {{ __('Delete Account') }} 18 | 19 |
20 | 21 | 22 | 23 | 24 | {{ __('Delete Account') }} 25 | 26 | 27 | 28 | {{ __('Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} 29 | 30 |
31 | 37 | 38 | 39 |
40 |
41 | 42 | 43 | 44 | {{ __('Cancel') }} 45 | 46 | 47 | 48 | {{ __('Delete Account') }} 49 | 50 | 51 |
52 |
53 |
54 | -------------------------------------------------------------------------------- /resources/views/profile/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Profile') }} 5 |

6 |
7 | 8 |
9 |
10 | @if (Laravel\Fortify\Features::canUpdateProfileInformation()) 11 | @livewire('profile.update-profile-information-form') 12 | 13 | 14 | @endif 15 | 16 | @if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::updatePasswords())) 17 |
18 | @livewire('profile.update-password-form') 19 |
20 | 21 | 22 | @endif 23 | 24 | @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) 25 |
26 | @livewire('profile.two-factor-authentication-form') 27 |
28 | 29 | 30 | @endif 31 | 32 |
33 | @livewire('profile.logout-other-browser-sessions-form') 34 |
35 | 36 | @if (Laravel\Jetstream\Jetstream::hasAccountDeletionFeatures()) 37 | 38 | 39 |
40 | @livewire('profile.delete-user-form') 41 |
42 | @endif 43 |
44 |
45 |
46 | -------------------------------------------------------------------------------- /resources/views/profile/update-password-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Update Password') }} 4 | 5 | 6 | 7 | {{ __('Ensure your account is using a long, random password to stay secure.') }} 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 | {{ __('Saved.') }} 33 | 34 | 35 | 36 | {{ __('Save') }} 37 | 38 | 39 |
40 | -------------------------------------------------------------------------------- /resources/views/teams/create-team-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Team Details') }} 4 | 5 | 6 | 7 | {{ __('Create a new team to collaborate with others on projects.') }} 8 | 9 | 10 | 11 |
12 | 13 | 14 |
15 | {{ $this->user->name }} 16 | 17 |
18 |
{{ $this->user->name }}
19 |
{{ $this->user->email }}
20 |
21 |
22 |
23 | 24 |
25 | 26 | 27 | 28 |
29 |
30 | 31 | 32 | 33 | {{ __('Create') }} 34 | 35 | 36 |
37 | -------------------------------------------------------------------------------- /resources/views/teams/create.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Create Team') }} 5 |

6 |
7 | 8 |
9 |
10 | @livewire('teams.create-team-form') 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /resources/views/teams/delete-team-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Delete Team') }} 4 | 5 | 6 | 7 | {{ __('Permanently delete this team.') }} 8 | 9 | 10 | 11 |
12 | {{ __('Once a team is deleted, all of its resources and data will be permanently deleted. Before deleting this team, please download any data or information regarding this team that you wish to retain.') }} 13 |
14 | 15 |
16 | 17 | {{ __('Delete Team') }} 18 | 19 |
20 | 21 | 22 | 23 | 24 | {{ __('Delete Team') }} 25 | 26 | 27 | 28 | {{ __('Are you sure you want to delete this team? Once a team is deleted, all of its resources and data will be permanently deleted.') }} 29 | 30 | 31 | 32 | 33 | {{ __('Cancel') }} 34 | 35 | 36 | 37 | {{ __('Delete Team') }} 38 | 39 | 40 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /resources/views/teams/show.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | {{ __('Team Settings') }} 5 |

6 |
7 | 8 |
9 |
10 | @livewire('teams.update-team-name-form', ['team' => $team]) 11 | 12 | @livewire('teams.team-member-manager', ['team' => $team]) 13 | 14 | @if (Gate::check('delete', $team) && ! $team->personal_team) 15 | 16 | 17 |
18 | @livewire('teams.delete-team-form', ['team' => $team]) 19 |
20 | @endif 21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /resources/views/teams/update-team-name-form.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ __('Team Name') }} 4 | 5 | 6 | 7 | {{ __('The team\'s name and owner information.') }} 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 |
16 | {{ $team->owner->name }} 17 | 18 |
19 |
{{ $team->owner->name }}
20 |
{{ $team->owner->email }}
21 |
22 |
23 |
24 | 25 | 26 |
27 | 28 | 29 | 34 | 35 | 36 |
37 |
38 | 39 | @if (Gate::check('update', $team)) 40 | 41 | 42 | {{ __('Saved.') }} 43 | 44 | 45 | 46 | {{ __('Save') }} 47 | 48 | 49 | @endif 50 |
51 | -------------------------------------------------------------------------------- /resources/views/terms.blade.php: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
7 | 8 |
9 | {!! $terms !!} 10 |
11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | user(); 8 | })->middleware('auth:sanctum'); 9 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | comment(Inspiring::quote()); 9 | })->purpose('Display an inspiring quote')->hourly(); 10 | 11 | Schedule::command('cloudflare:reload')->daily(); 12 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | group(function () { 15 | Route::get('/dashboard', function () { 16 | return redirect()->to('/app'); 17 | })->name('dashboard'); 18 | 19 | 20 | Route::get('/a/{asset_id}', function (string $asset_id) { 21 | $team_id = auth()?->user()?->currentTeam()?->getChild()?->id ?: auth()->user()->ownedTeams()->first()->id; 22 | 23 | // todo: put this logic in one place 24 | $asset_id = ltrim($asset_id, '0'); 25 | $asset_id = ltrim($asset_id, '-'); 26 | 27 | $asset = \App\Models\Item::where('asset_id', $asset_id) 28 | ->where('team_id', $team_id) 29 | ->first(); 30 | if (! $asset) { 31 | // create new empty one with this asset id 32 | $asset = new \App\Models\Item(); 33 | $asset->asset_id = $asset_id; 34 | $asset->team_id = $team_id; 35 | $asset->save(); 36 | } 37 | 38 | return redirect()->to(ItemResource::getUrl('edit', ['record' => $asset, 'tenant' => $team_id])); 39 | })->name('asset'); 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /storage/app/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !public/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/app/public/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/debugbar/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /storage/framework/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !data/ 3 | !.gitignore 4 | -------------------------------------------------------------------------------- /storage/framework/cache/data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/sessions/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/testing/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/framework/views/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /storage/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import defaultTheme from 'tailwindcss/defaultTheme'; 2 | import forms from '@tailwindcss/forms'; 3 | import typography from '@tailwindcss/typography'; 4 | 5 | /** @type {import('tailwindcss').Config} */ 6 | export default { 7 | content: [ 8 | './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', 9 | './vendor/laravel/jetstream/**/*.blade.php', 10 | './storage/framework/views/*.php', 11 | './resources/views/**/*.blade.php', 12 | ], 13 | 14 | theme: { 15 | extend: { 16 | fontFamily: { 17 | sans: ['Figtree', ...defaultTheme.fontFamily.sans], 18 | }, 19 | }, 20 | }, 21 | 22 | plugins: [forms, typography], 23 | }; 24 | -------------------------------------------------------------------------------- /tests/Feature/ApiTokenPermissionsTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 21 | } 22 | 23 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 24 | 25 | $token = $user->tokens()->create([ 26 | 'name' => 'Test Token', 27 | 'token' => Str::random(40), 28 | 'abilities' => ['create', 'read'], 29 | ]); 30 | 31 | Livewire::test(ApiTokenManager::class) 32 | ->set(['managingPermissionsFor' => $token]) 33 | ->set(['updateApiTokenForm' => [ 34 | 'permissions' => [ 35 | 'delete', 36 | 'missing-permission', 37 | ], 38 | ]]) 39 | ->call('updateApiToken'); 40 | 41 | $this->assertTrue($user->fresh()->tokens->first()->can('delete')); 42 | $this->assertFalse($user->fresh()->tokens->first()->can('read')); 43 | $this->assertFalse($user->fresh()->tokens->first()->can('missing-permission')); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/AuthenticationTest.php: -------------------------------------------------------------------------------- 1 | get('/login'); 16 | 17 | $response->assertStatus(200); 18 | } 19 | 20 | public function test_users_can_authenticate_using_the_login_screen(): void 21 | { 22 | $user = User::factory()->create(); 23 | 24 | $response = $this->post('/login', [ 25 | 'email' => $user->email, 26 | 'password' => 'password', 27 | ]); 28 | 29 | $this->assertAuthenticated(); 30 | $response->assertRedirect(route('dashboard', absolute: false)); 31 | } 32 | 33 | public function test_users_can_not_authenticate_with_invalid_password(): void 34 | { 35 | $user = User::factory()->create(); 36 | 37 | $this->post('/login', [ 38 | 'email' => $user->email, 39 | 'password' => 'wrong-password', 40 | ]); 41 | 42 | $this->assertGuest(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Feature/BrowserSessionsTest.php: -------------------------------------------------------------------------------- 1 | actingAs(User::factory()->create()); 18 | 19 | Livewire::test(LogoutOtherBrowserSessionsForm::class) 20 | ->set('password', 'password') 21 | ->call('logoutOtherBrowserSessions') 22 | ->assertSuccessful(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Feature/CreateApiTokenTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 20 | } 21 | 22 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 23 | 24 | Livewire::test(ApiTokenManager::class) 25 | ->set(['createApiTokenForm' => [ 26 | 'name' => 'Test Token', 27 | 'permissions' => [ 28 | 'read', 29 | 'update', 30 | ], 31 | ]]) 32 | ->call('createApiToken'); 33 | 34 | $this->assertCount(1, $user->fresh()->tokens); 35 | $this->assertEquals('Test Token', $user->fresh()->tokens->first()->name); 36 | $this->assertTrue($user->fresh()->tokens->first()->can('read')); 37 | $this->assertFalse($user->fresh()->tokens->first()->can('delete')); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Feature/CreateTeamTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->withPersonalTeam()->create()); 18 | 19 | Livewire::test(CreateTeamForm::class) 20 | ->set(['state' => ['name' => 'Test Team']]) 21 | ->call('createTeam'); 22 | 23 | $this->assertCount(2, $user->fresh()->ownedTeams); 24 | $this->assertEquals('Test Team', $user->fresh()->ownedTeams()->latest('id')->first()->name); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Feature/DeleteAccountTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Account deletion is not enabled.'); 20 | } 21 | 22 | $this->actingAs($user = User::factory()->create()); 23 | 24 | $component = Livewire::test(DeleteUserForm::class) 25 | ->set('password', 'password') 26 | ->call('deleteUser'); 27 | 28 | $this->assertNull($user->fresh()); 29 | } 30 | 31 | public function test_correct_password_must_be_provided_before_account_can_be_deleted(): void 32 | { 33 | if (! Features::hasAccountDeletionFeatures()) { 34 | $this->markTestSkipped('Account deletion is not enabled.'); 35 | } 36 | 37 | $this->actingAs($user = User::factory()->create()); 38 | 39 | Livewire::test(DeleteUserForm::class) 40 | ->set('password', 'wrong-password') 41 | ->call('deleteUser') 42 | ->assertHasErrors(['password']); 43 | 44 | $this->assertNotNull($user->fresh()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Feature/DeleteApiTokenTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('API support is not enabled.'); 21 | } 22 | 23 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 24 | 25 | $token = $user->tokens()->create([ 26 | 'name' => 'Test Token', 27 | 'token' => Str::random(40), 28 | 'abilities' => ['create', 'read'], 29 | ]); 30 | 31 | Livewire::test(ApiTokenManager::class) 32 | ->set(['apiTokenIdBeingDeleted' => $token->id]) 33 | ->call('deleteApiToken'); 34 | 35 | $this->assertCount(0, $user->fresh()->tokens); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Feature/DeleteTeamTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->withPersonalTeam()->create()); 19 | 20 | $user->ownedTeams()->save($team = Team::factory()->make([ 21 | 'personal_team' => false, 22 | ])); 23 | 24 | $team->users()->attach( 25 | $otherUser = User::factory()->create(), ['role' => 'test-role'] 26 | ); 27 | 28 | Livewire::test(DeleteTeamForm::class, ['team' => $team->fresh()]) 29 | ->call('deleteTeam'); 30 | 31 | $this->assertNull($team->fresh()); 32 | $this->assertCount(0, $otherUser->fresh()->teams); 33 | } 34 | 35 | public function test_personal_teams_cant_be_deleted(): void 36 | { 37 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 38 | 39 | Livewire::test(DeleteTeamForm::class, ['team' => $user->currentTeam]) 40 | ->call('deleteTeam') 41 | ->assertHasErrors(['team']); 42 | 43 | $this->assertNotNull($user->currentTeam->fresh()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/EmailVerificationTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Email verification not enabled.'); 21 | } 22 | 23 | $user = User::factory()->withPersonalTeam()->unverified()->create(); 24 | 25 | $response = $this->actingAs($user)->get('/email/verify'); 26 | 27 | $response->assertStatus(200); 28 | } 29 | 30 | public function test_email_can_be_verified(): void 31 | { 32 | if (! Features::enabled(Features::emailVerification())) { 33 | $this->markTestSkipped('Email verification not enabled.'); 34 | } 35 | 36 | Event::fake(); 37 | 38 | $user = User::factory()->unverified()->create(); 39 | 40 | $verificationUrl = URL::temporarySignedRoute( 41 | 'verification.verify', 42 | now()->addMinutes(60), 43 | ['id' => $user->id, 'hash' => sha1($user->email)] 44 | ); 45 | 46 | $response = $this->actingAs($user)->get($verificationUrl); 47 | 48 | Event::assertDispatched(Verified::class); 49 | 50 | $this->assertTrue($user->fresh()->hasVerifiedEmail()); 51 | $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); 52 | } 53 | 54 | public function test_email_can_not_verified_with_invalid_hash(): void 55 | { 56 | if (! Features::enabled(Features::emailVerification())) { 57 | $this->markTestSkipped('Email verification not enabled.'); 58 | } 59 | 60 | $user = User::factory()->unverified()->create(); 61 | 62 | $verificationUrl = URL::temporarySignedRoute( 63 | 'verification.verify', 64 | now()->addMinutes(60), 65 | ['id' => $user->id, 'hash' => sha1('wrong-email')] 66 | ); 67 | 68 | $this->actingAs($user)->get($verificationUrl); 69 | 70 | $this->assertFalse($user->fresh()->hasVerifiedEmail()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/Feature/ExampleTest.php: -------------------------------------------------------------------------------- 1 | get('/'); 16 | 17 | $response->assertStatus(200); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Feature/InviteTeamMemberTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Team invitations not enabled.'); 22 | } 23 | 24 | Mail::fake(); 25 | 26 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 27 | 28 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 29 | ->set('addTeamMemberForm', [ 30 | 'email' => 'test@example.com', 31 | 'role' => 'admin', 32 | ])->call('addTeamMember'); 33 | 34 | Mail::assertSent(TeamInvitation::class); 35 | 36 | $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); 37 | } 38 | 39 | public function test_team_member_invitations_can_be_cancelled(): void 40 | { 41 | if (! Features::sendsTeamInvitations()) { 42 | $this->markTestSkipped('Team invitations not enabled.'); 43 | } 44 | 45 | Mail::fake(); 46 | 47 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 48 | 49 | // Add the team member... 50 | $component = Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 51 | ->set('addTeamMemberForm', [ 52 | 'email' => 'test@example.com', 53 | 'role' => 'admin', 54 | ])->call('addTeamMember'); 55 | 56 | $invitationId = $user->currentTeam->fresh()->teamInvitations->first()->id; 57 | 58 | // Cancel the team invitation... 59 | $component->call('cancelTeamInvitation', $invitationId); 60 | 61 | $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Feature/LeaveTeamTest.php: -------------------------------------------------------------------------------- 1 | withPersonalTeam()->create(); 18 | 19 | $user->currentTeam->users()->attach( 20 | $otherUser = User::factory()->create(), ['role' => 'admin'] 21 | ); 22 | 23 | $this->actingAs($otherUser); 24 | 25 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 26 | ->call('leaveTeam'); 27 | 28 | $this->assertCount(0, $user->currentTeam->fresh()->users); 29 | } 30 | 31 | public function test_team_owners_cant_leave_their_own_team(): void 32 | { 33 | $this->actingAs($user = User::factory()->withPersonalTeam()->create()); 34 | 35 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 36 | ->call('leaveTeam') 37 | ->assertHasErrors(['team']); 38 | 39 | $this->assertNotNull($user->currentTeam->fresh()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Feature/PasswordConfirmationTest.php: -------------------------------------------------------------------------------- 1 | withPersonalTeam()->create(); 16 | 17 | $response = $this->actingAs($user)->get('/user/confirm-password'); 18 | 19 | $response->assertStatus(200); 20 | } 21 | 22 | public function test_password_can_be_confirmed(): void 23 | { 24 | $user = User::factory()->create(); 25 | 26 | $response = $this->actingAs($user)->post('/user/confirm-password', [ 27 | 'password' => 'password', 28 | ]); 29 | 30 | $response->assertRedirect(); 31 | $response->assertSessionHasNoErrors(); 32 | } 33 | 34 | public function test_password_is_not_confirmed_with_invalid_password(): void 35 | { 36 | $user = User::factory()->create(); 37 | 38 | $response = $this->actingAs($user)->post('/user/confirm-password', [ 39 | 'password' => 'wrong-password', 40 | ]); 41 | 42 | $response->assertSessionHasErrors(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Feature/PasswordResetTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Password updates are not enabled.'); 20 | } 21 | 22 | $response = $this->get('/forgot-password'); 23 | 24 | $response->assertStatus(200); 25 | } 26 | 27 | public function test_reset_password_link_can_be_requested(): void 28 | { 29 | if (! Features::enabled(Features::resetPasswords())) { 30 | $this->markTestSkipped('Password updates are not enabled.'); 31 | } 32 | 33 | Notification::fake(); 34 | 35 | $user = User::factory()->create(); 36 | 37 | $this->post('/forgot-password', [ 38 | 'email' => $user->email, 39 | ]); 40 | 41 | Notification::assertSentTo($user, ResetPassword::class); 42 | } 43 | 44 | public function test_reset_password_screen_can_be_rendered(): void 45 | { 46 | if (! Features::enabled(Features::resetPasswords())) { 47 | $this->markTestSkipped('Password updates are not enabled.'); 48 | } 49 | 50 | Notification::fake(); 51 | 52 | $user = User::factory()->create(); 53 | 54 | $this->post('/forgot-password', [ 55 | 'email' => $user->email, 56 | ]); 57 | 58 | Notification::assertSentTo($user, ResetPassword::class, function (object $notification) { 59 | $response = $this->get('/reset-password/'.$notification->token); 60 | 61 | $response->assertStatus(200); 62 | 63 | return true; 64 | }); 65 | } 66 | 67 | public function test_password_can_be_reset_with_valid_token(): void 68 | { 69 | if (! Features::enabled(Features::resetPasswords())) { 70 | $this->markTestSkipped('Password updates are not enabled.'); 71 | } 72 | 73 | Notification::fake(); 74 | 75 | $user = User::factory()->create(); 76 | 77 | $this->post('/forgot-password', [ 78 | 'email' => $user->email, 79 | ]); 80 | 81 | Notification::assertSentTo($user, ResetPassword::class, function (object $notification) use ($user) { 82 | $response = $this->post('/reset-password', [ 83 | 'token' => $notification->token, 84 | 'email' => $user->email, 85 | 'password' => 'password', 86 | 'password_confirmation' => 'password', 87 | ]); 88 | 89 | $response->assertSessionHasNoErrors(); 90 | 91 | return true; 92 | }); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Feature/ProfileInformationTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->create()); 18 | 19 | $component = Livewire::test(UpdateProfileInformationForm::class); 20 | 21 | $this->assertEquals($user->name, $component->state['name']); 22 | $this->assertEquals($user->email, $component->state['email']); 23 | } 24 | 25 | public function test_profile_information_can_be_updated(): void 26 | { 27 | $this->actingAs($user = User::factory()->create()); 28 | 29 | Livewire::test(UpdateProfileInformationForm::class) 30 | ->set('state', ['name' => 'Test Name', 'email' => 'test@example.com']) 31 | ->call('updateProfileInformation'); 32 | 33 | $this->assertEquals('Test Name', $user->fresh()->name); 34 | $this->assertEquals('test@example.com', $user->fresh()->email); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Feature/RegistrationTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Registration support is not enabled.'); 18 | } 19 | 20 | $response = $this->get('/register'); 21 | 22 | $response->assertStatus(200); 23 | } 24 | 25 | public function test_registration_screen_cannot_be_rendered_if_support_is_disabled(): void 26 | { 27 | if (Features::enabled(Features::registration())) { 28 | $this->markTestSkipped('Registration support is enabled.'); 29 | } 30 | 31 | $response = $this->get('/register'); 32 | 33 | $response->assertStatus(404); 34 | } 35 | 36 | public function test_new_users_can_register(): void 37 | { 38 | if (! Features::enabled(Features::registration())) { 39 | $this->markTestSkipped('Registration support is not enabled.'); 40 | } 41 | 42 | $response = $this->post('/register', [ 43 | 'name' => 'Test User', 44 | 'email' => 'test@example.com', 45 | 'password' => 'password', 46 | 'password_confirmation' => 'password', 47 | 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), 48 | ]); 49 | 50 | $this->assertAuthenticated(); 51 | $response->assertRedirect(route('dashboard', absolute: false)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/RemoveTeamMemberTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->withPersonalTeam()->create()); 18 | 19 | $user->currentTeam->users()->attach( 20 | $otherUser = User::factory()->create(), ['role' => 'admin'] 21 | ); 22 | 23 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 24 | ->set('teamMemberIdBeingRemoved', $otherUser->id) 25 | ->call('removeTeamMember'); 26 | 27 | $this->assertCount(0, $user->currentTeam->fresh()->users); 28 | } 29 | 30 | public function test_only_team_owner_can_remove_team_members(): void 31 | { 32 | $user = User::factory()->withPersonalTeam()->create(); 33 | 34 | $user->currentTeam->users()->attach( 35 | $otherUser = User::factory()->create(), ['role' => 'admin'] 36 | ); 37 | 38 | $this->actingAs($otherUser); 39 | 40 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 41 | ->set('teamMemberIdBeingRemoved', $user->id) 42 | ->call('removeTeamMember') 43 | ->assertStatus(403); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Feature/TwoFactorAuthenticationSettingsTest.php: -------------------------------------------------------------------------------- 1 | markTestSkipped('Two factor authentication is not enabled.'); 20 | } 21 | 22 | $this->actingAs($user = User::factory()->create()); 23 | 24 | $this->withSession(['auth.password_confirmed_at' => time()]); 25 | 26 | Livewire::test(TwoFactorAuthenticationForm::class) 27 | ->call('enableTwoFactorAuthentication'); 28 | 29 | $user = $user->fresh(); 30 | 31 | $this->assertNotNull($user->two_factor_secret); 32 | $this->assertCount(8, $user->recoveryCodes()); 33 | } 34 | 35 | public function test_recovery_codes_can_be_regenerated(): void 36 | { 37 | if (! Features::canManageTwoFactorAuthentication()) { 38 | $this->markTestSkipped('Two factor authentication is not enabled.'); 39 | } 40 | 41 | $this->actingAs($user = User::factory()->create()); 42 | 43 | $this->withSession(['auth.password_confirmed_at' => time()]); 44 | 45 | $component = Livewire::test(TwoFactorAuthenticationForm::class) 46 | ->call('enableTwoFactorAuthentication') 47 | ->call('regenerateRecoveryCodes'); 48 | 49 | $user = $user->fresh(); 50 | 51 | $component->call('regenerateRecoveryCodes'); 52 | 53 | $this->assertCount(8, $user->recoveryCodes()); 54 | $this->assertCount(8, array_diff($user->recoveryCodes(), $user->fresh()->recoveryCodes())); 55 | } 56 | 57 | public function test_two_factor_authentication_can_be_disabled(): void 58 | { 59 | if (! Features::canManageTwoFactorAuthentication()) { 60 | $this->markTestSkipped('Two factor authentication is not enabled.'); 61 | } 62 | 63 | $this->actingAs($user = User::factory()->create()); 64 | 65 | $this->withSession(['auth.password_confirmed_at' => time()]); 66 | 67 | $component = Livewire::test(TwoFactorAuthenticationForm::class) 68 | ->call('enableTwoFactorAuthentication'); 69 | 70 | $this->assertNotNull($user->fresh()->two_factor_secret); 71 | 72 | $component->call('disableTwoFactorAuthentication'); 73 | 74 | $this->assertNull($user->fresh()->two_factor_secret); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Feature/UpdatePasswordTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->create()); 19 | 20 | Livewire::test(UpdatePasswordForm::class) 21 | ->set('state', [ 22 | 'current_password' => 'password', 23 | 'password' => 'new-password', 24 | 'password_confirmation' => 'new-password', 25 | ]) 26 | ->call('updatePassword'); 27 | 28 | $this->assertTrue(Hash::check('new-password', $user->fresh()->password)); 29 | } 30 | 31 | public function test_current_password_must_be_correct(): void 32 | { 33 | $this->actingAs($user = User::factory()->create()); 34 | 35 | Livewire::test(UpdatePasswordForm::class) 36 | ->set('state', [ 37 | 'current_password' => 'wrong-password', 38 | 'password' => 'new-password', 39 | 'password_confirmation' => 'new-password', 40 | ]) 41 | ->call('updatePassword') 42 | ->assertHasErrors(['current_password']); 43 | 44 | $this->assertTrue(Hash::check('password', $user->fresh()->password)); 45 | } 46 | 47 | public function test_new_passwords_must_match(): void 48 | { 49 | $this->actingAs($user = User::factory()->create()); 50 | 51 | Livewire::test(UpdatePasswordForm::class) 52 | ->set('state', [ 53 | 'current_password' => 'password', 54 | 'password' => 'new-password', 55 | 'password_confirmation' => 'wrong-password', 56 | ]) 57 | ->call('updatePassword') 58 | ->assertHasErrors(['password']); 59 | 60 | $this->assertTrue(Hash::check('password', $user->fresh()->password)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Feature/UpdateTeamMemberRoleTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->withPersonalTeam()->create()); 18 | 19 | $user->currentTeam->users()->attach( 20 | $otherUser = User::factory()->create(), ['role' => 'admin'] 21 | ); 22 | 23 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 24 | ->set('managingRoleFor', $otherUser) 25 | ->set('currentRole', 'editor') 26 | ->call('updateRole'); 27 | 28 | $this->assertTrue($otherUser->fresh()->hasTeamRole( 29 | $user->currentTeam->fresh(), 'editor' 30 | )); 31 | } 32 | 33 | public function test_only_team_owner_can_update_team_member_roles(): void 34 | { 35 | $user = User::factory()->withPersonalTeam()->create(); 36 | 37 | $user->currentTeam->users()->attach( 38 | $otherUser = User::factory()->create(), ['role' => 'admin'] 39 | ); 40 | 41 | $this->actingAs($otherUser); 42 | 43 | Livewire::test(TeamMemberManager::class, ['team' => $user->currentTeam]) 44 | ->set('managingRoleFor', $otherUser) 45 | ->set('currentRole', 'editor') 46 | ->call('updateRole') 47 | ->assertStatus(403); 48 | 49 | $this->assertTrue($otherUser->fresh()->hasTeamRole( 50 | $user->currentTeam->fresh(), 'admin' 51 | )); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Feature/UpdateTeamNameTest.php: -------------------------------------------------------------------------------- 1 | actingAs($user = User::factory()->withPersonalTeam()->create()); 18 | 19 | Livewire::test(UpdateTeamNameForm::class, ['team' => $user->currentTeam]) 20 | ->set(['state' => ['name' => 'Test Team']]) 21 | ->call('updateTeamName'); 22 | 23 | $this->assertCount(1, $user->fresh()->ownedTeams); 24 | $this->assertEquals('Test Team', $user->currentTeam->fresh()->name); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | assertTrue(true); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import laravel from 'laravel-vite-plugin'; 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | laravel({ 7 | input: [ 8 | 'resources/css/app.css', 9 | 'resources/js/app.js', 10 | ], 11 | refresh: true, 12 | }), 13 | ], 14 | }); 15 | --------------------------------------------------------------------------------