From 6dad49932d93aea8b9bc8cf6912a0fa525d32e4f Mon Sep 17 00:00:00 2001 From: Fady Mondy Date: Fri, 5 Jan 2024 21:36:11 +0200 Subject: [PATCH] add jetstream integration --- composer.json | 5 +- .../2014_10_12_000000_update_users_table.php | 30 +++ ..._add_two_factor_columns_to_users_table.php | 46 +++++ .../2020_05_21_100000_create_teams_table.php | 32 ++++ ...20_05_21_200000_create_team_user_table.php | 34 ++++ ...1_300000_create_team_invitations_table.php | 33 ++++ ...023_01_01_000001_create_sessions_table.php | 32 ++++ publish/emails/team-invitation.blade.php | 23 +++ publish/markdown/policy.md | 139 ++++++++++++++ publish/markdown/terms.md | 5 + resources/markdown/policy.md | 139 ++++++++++++++ resources/markdown/terms.md | 5 + resources/views/api/edit.blade.php | 19 ++ resources/views/api/index.blade.php | 140 ++++++++++++++ .../views/auth/forgot-password.blade.php | 2 +- resources/views/auth/login.blade.php | 6 +- resources/views/auth/register.blade.php | 14 +- .../views/auth/two-factor-challenge.blade.php | 46 +++++ .../components/application-logo.blade.php | 12 ++ .../components/auth-session-status.blade.php | 3 + .../views/components/clipboard.blade.php | 9 +- .../views/components/dropdown-item.blade.php | 19 +- resources/views/components/dropdown.blade.php | 6 +- .../components/profile-dropdown.blade.php | 172 +++++++----------- resources/views/components/row.blade.php | 47 ++++- .../views/emails/team-invitation.blade.php | 23 +++ resources/views/layouts/guest.blade.php | 4 +- .../views/layouts/includes/aside.blade.php | 2 +- .../views/layouts/includes/menu.blade.php | 4 +- resources/views/pages/policy.blade.php | 13 ++ resources/views/pages/terms.blade.php | 14 ++ resources/views/profile/edit.blade.php | 24 ++- ...gout-other-browser-sessions-form.blade.php | 70 +++++++ .../two-factor-authentication-form.blade.php | 120 ++++++++++++ .../partials/update-password-form.blade.php | 7 +- .../update-profile-information-form.blade.php | 103 ++++++++--- .../views/teams/add-team-member.blade.php | 64 +++++++ resources/views/teams/create.blade.php | 52 ++++++ .../views/teams/delete-team-form.blade.php | 27 +++ .../views/teams/manage-team-members.blade.php | 61 +++++++ .../views/teams/member-role-form.blade.php | 52 ++++++ resources/views/teams/show.blade.php | 49 +++++ .../teams/team-member-invitations.blade.php | 38 ++++ .../teams/team-member-role-form.blade.php | 41 +++++ .../teams/update-team-name-form.blade.php | 43 +++++ routes/web.php | 71 ++++++-- src/Actions/Fortify/CreateNewUser.php | 56 ++++++ .../Fortify/CreateNewUserWithTeams.php | 53 ++++++ .../Fortify/PasswordValidationRules.php | 21 +++ src/Actions/Fortify/ResetUserPassword.php | 32 ++++ src/Actions/Fortify/UpdateUserPassword.php | 35 ++++ .../Fortify/UpdateUserProfileInformation.php | 59 ++++++ src/Actions/Jetstream/AddTeamMember.php | 81 +++++++++ src/Actions/Jetstream/CreateTeam.php | 37 ++++ src/Actions/Jetstream/DeleteTeam.php | 17 ++ src/Actions/Jetstream/DeleteUser.php | 19 ++ src/Actions/Jetstream/DeleteUserWithTeams.php | 52 ++++++ src/Actions/Jetstream/InviteTeamMember.php | 88 +++++++++ src/Actions/Jetstream/RemoveTeamMember.php | 51 ++++++ src/Actions/Jetstream/UpdateTeamName.php | 30 +++ src/Console/TomatoAdminInstall.php | 3 +- src/Http/Controllers/ApiTokenController.php | 105 +++++++++++ .../ConfirmsTwoFactorAuthentication.php | 84 +++++++++ .../Controllers/CurrentTeamController.php | 27 +++ src/Http/Controllers/DashboardController.php | 147 ++++++++------- .../OtherBrowserSessionsController.php | 57 ++++++ .../Controllers/PrivacyPolicyController.php | 26 +++ .../Controllers/ProfilePhotoController.php | 22 +++ .../FailedPasswordConfirmationResponse.php | 24 +++ .../Controllers/Responses/LoginResponse.php | 20 ++ .../Controllers/Responses/LogoutResponse.php | 20 ++ .../Responses/PasswordConfirmedResponse.php | 20 ++ .../Responses/PasswordResetResponse.php | 38 ++++ .../Responses/PasswordUpdateResponse.php | 20 ++ .../ProfileInformationUpdatedResponse.php | 20 ++ .../RecoveryCodesGeneratedResponse.php | 20 ++ .../Responses/RegisterResponse.php | 20 ++ ...essfulPasswordResetLinkRequestResponse.php | 37 ++++ .../Responses/TwoFactorConfirmedResponse.php | 20 ++ .../Responses/TwoFactorDisabledResponse.php | 20 ++ .../Responses/TwoFactorEnabledResponse.php | 20 ++ .../Responses/TwoFactorLoginResponse.php | 20 ++ .../Responses/VerifyEmailResponse.php | 20 ++ src/Http/Controllers/TeamController.php | 109 +++++++++++ .../Controllers/TeamInvitationController.php | 62 +++++++ src/Http/Controllers/TeamMemberController.php | 110 +++++++++++ .../Controllers/TermsOfServiceController.php | 26 +++ .../Controllers/UserProfileController.php | 146 +++++++++++++++ src/Models/Membership.php | 15 ++ src/Models/Team.php | 73 ++++++++ src/Models/TeamInvitation.php | 26 +++ src/Policies/TeamPolicy.php | 76 ++++++++ src/Providers/AuthServiceProvider.php | 29 +++ src/Providers/FortifyServiceProvider.php | 65 +++++++ src/Providers/JetstreamServiceProvider.php | 45 +++++ .../JetstreamWithTeamsServiceProvider.php | 61 +++++++ src/Services/TeamSubscriptionOptions.php | 107 +++++++++++ src/TomatoAdminServiceProvider.php | 78 +++++++- src/Views/ApplicationLogo.php | 19 ++ src/Views/AuthSessionStatus.php | 19 ++ src/Views/Row.php | 1 + 101 files changed, 4148 insertions(+), 260 deletions(-) create mode 100644 database/migrations/2014_10_12_000000_update_users_table.php create mode 100644 database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php create mode 100644 database/migrations/2020_05_21_100000_create_teams_table.php create mode 100644 database/migrations/2020_05_21_200000_create_team_user_table.php create mode 100644 database/migrations/2020_05_21_300000_create_team_invitations_table.php create mode 100644 database/migrations/2023_01_01_000001_create_sessions_table.php create mode 100644 publish/emails/team-invitation.blade.php create mode 100644 publish/markdown/policy.md create mode 100644 publish/markdown/terms.md create mode 100644 resources/markdown/policy.md create mode 100644 resources/markdown/terms.md create mode 100644 resources/views/api/edit.blade.php create mode 100644 resources/views/api/index.blade.php create mode 100644 resources/views/auth/two-factor-challenge.blade.php create mode 100644 resources/views/components/application-logo.blade.php create mode 100644 resources/views/components/auth-session-status.blade.php create mode 100644 resources/views/emails/team-invitation.blade.php create mode 100644 resources/views/pages/policy.blade.php create mode 100644 resources/views/pages/terms.blade.php create mode 100644 resources/views/profile/partials/logout-other-browser-sessions-form.blade.php create mode 100644 resources/views/profile/partials/two-factor-authentication-form.blade.php create mode 100644 resources/views/teams/add-team-member.blade.php create mode 100644 resources/views/teams/create.blade.php create mode 100644 resources/views/teams/delete-team-form.blade.php create mode 100644 resources/views/teams/manage-team-members.blade.php create mode 100644 resources/views/teams/member-role-form.blade.php create mode 100644 resources/views/teams/show.blade.php create mode 100644 resources/views/teams/team-member-invitations.blade.php create mode 100644 resources/views/teams/team-member-role-form.blade.php create mode 100644 resources/views/teams/update-team-name-form.blade.php create mode 100644 src/Actions/Fortify/CreateNewUser.php create mode 100644 src/Actions/Fortify/CreateNewUserWithTeams.php create mode 100644 src/Actions/Fortify/PasswordValidationRules.php create mode 100644 src/Actions/Fortify/ResetUserPassword.php create mode 100644 src/Actions/Fortify/UpdateUserPassword.php create mode 100644 src/Actions/Fortify/UpdateUserProfileInformation.php create mode 100644 src/Actions/Jetstream/AddTeamMember.php create mode 100644 src/Actions/Jetstream/CreateTeam.php create mode 100644 src/Actions/Jetstream/DeleteTeam.php create mode 100644 src/Actions/Jetstream/DeleteUser.php create mode 100644 src/Actions/Jetstream/DeleteUserWithTeams.php create mode 100644 src/Actions/Jetstream/InviteTeamMember.php create mode 100644 src/Actions/Jetstream/RemoveTeamMember.php create mode 100644 src/Actions/Jetstream/UpdateTeamName.php create mode 100644 src/Http/Controllers/ApiTokenController.php create mode 100644 src/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php create mode 100644 src/Http/Controllers/CurrentTeamController.php create mode 100644 src/Http/Controllers/OtherBrowserSessionsController.php create mode 100644 src/Http/Controllers/PrivacyPolicyController.php create mode 100644 src/Http/Controllers/ProfilePhotoController.php create mode 100644 src/Http/Controllers/Responses/FailedPasswordConfirmationResponse.php create mode 100644 src/Http/Controllers/Responses/LoginResponse.php create mode 100644 src/Http/Controllers/Responses/LogoutResponse.php create mode 100644 src/Http/Controllers/Responses/PasswordConfirmedResponse.php create mode 100644 src/Http/Controllers/Responses/PasswordResetResponse.php create mode 100644 src/Http/Controllers/Responses/PasswordUpdateResponse.php create mode 100644 src/Http/Controllers/Responses/ProfileInformationUpdatedResponse.php create mode 100644 src/Http/Controllers/Responses/RecoveryCodesGeneratedResponse.php create mode 100644 src/Http/Controllers/Responses/RegisterResponse.php create mode 100644 src/Http/Controllers/Responses/SuccessfulPasswordResetLinkRequestResponse.php create mode 100644 src/Http/Controllers/Responses/TwoFactorConfirmedResponse.php create mode 100644 src/Http/Controllers/Responses/TwoFactorDisabledResponse.php create mode 100644 src/Http/Controllers/Responses/TwoFactorEnabledResponse.php create mode 100644 src/Http/Controllers/Responses/TwoFactorLoginResponse.php create mode 100644 src/Http/Controllers/Responses/VerifyEmailResponse.php create mode 100644 src/Http/Controllers/TeamController.php create mode 100644 src/Http/Controllers/TeamInvitationController.php create mode 100644 src/Http/Controllers/TeamMemberController.php create mode 100644 src/Http/Controllers/TermsOfServiceController.php create mode 100644 src/Http/Controllers/UserProfileController.php create mode 100644 src/Models/Membership.php create mode 100644 src/Models/Team.php create mode 100644 src/Models/TeamInvitation.php create mode 100644 src/Policies/TeamPolicy.php create mode 100644 src/Providers/AuthServiceProvider.php create mode 100644 src/Providers/FortifyServiceProvider.php create mode 100644 src/Providers/JetstreamServiceProvider.php create mode 100644 src/Providers/JetstreamWithTeamsServiceProvider.php create mode 100644 src/Services/TeamSubscriptionOptions.php create mode 100644 src/Views/ApplicationLogo.php create mode 100644 src/Views/AuthSessionStatus.php diff --git a/composer.json b/composer.json index 033c99f..ade2b2d 100644 --- a/composer.json +++ b/composer.json @@ -48,12 +48,13 @@ "require": { "tomatophp/console-helpers": "^1.1", "tomatophp/tomato-splade": "^1.1", - "tomatophp/tomato-breeze": "^1.0", "tomatophp/tomato-plugins": "^1.1", "blade-ui-kit/blade-heroicons": "^2.0", "spatie/laravel-medialibrary": "^10.7", "maatwebsite/excel": "^3.1", - "kirschbaum-development/eloquent-power-joins": "^3.0" + "kirschbaum-development/eloquent-power-joins": "^3.0", + "laravel/fortify": "^1.15", + "laravel/jetstream": "^4.2" }, "scripts": { "post-update-cmd": [ diff --git a/database/migrations/2014_10_12_000000_update_users_table.php b/database/migrations/2014_10_12_000000_update_users_table.php new file mode 100644 index 0000000..0b42120 --- /dev/null +++ b/database/migrations/2014_10_12_000000_update_users_table.php @@ -0,0 +1,30 @@ +ulid('current_team_id')->nullable(); + $table->string('profile_photo_path', 2048)->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('current_team_id'); + $table->dropColumn('profile_photo_path'); + }); + } +}; diff --git a/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..b490e24 --- /dev/null +++ b/database/migrations/2014_10_12_200000_add_two_factor_columns_to_users_table.php @@ -0,0 +1,46 @@ +text('two_factor_secret') + ->after('password') + ->nullable(); + + $table->text('two_factor_recovery_codes') + ->after('two_factor_secret') + ->nullable(); + + if (Fortify::confirmsTwoFactorAuthentication()) { + $table->timestamp('two_factor_confirmed_at') + ->after('two_factor_recovery_codes') + ->nullable(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(array_merge([ + 'two_factor_secret', + 'two_factor_recovery_codes', + ], Fortify::confirmsTwoFactorAuthentication() ? [ + 'two_factor_confirmed_at', + ] : [])); + }); + } +}; diff --git a/database/migrations/2020_05_21_100000_create_teams_table.php b/database/migrations/2020_05_21_100000_create_teams_table.php new file mode 100644 index 0000000..21dcf72 --- /dev/null +++ b/database/migrations/2020_05_21_100000_create_teams_table.php @@ -0,0 +1,32 @@ +ulid('id')->primary(); + $table->foreignIdFor(User::class)->index(); + $table->string('name'); + $table->boolean('personal_team'); + $table->boolean('requires_subscription')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('teams'); + } +}; diff --git a/database/migrations/2020_05_21_200000_create_team_user_table.php b/database/migrations/2020_05_21_200000_create_team_user_table.php new file mode 100644 index 0000000..2c5f561 --- /dev/null +++ b/database/migrations/2020_05_21_200000_create_team_user_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(Team::class); + $table->foreignIdFor(User::class); + $table->string('role')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_user'); + } +}; diff --git a/database/migrations/2020_05_21_300000_create_team_invitations_table.php b/database/migrations/2020_05_21_300000_create_team_invitations_table.php new file mode 100644 index 0000000..97635e5 --- /dev/null +++ b/database/migrations/2020_05_21_300000_create_team_invitations_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->foreignIdFor(Team::class)->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('role')->nullable(); + $table->timestamps(); + + $table->unique(['team_id', 'email']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('team_invitations'); + } +}; diff --git a/database/migrations/2023_01_01_000001_create_sessions_table.php b/database/migrations/2023_01_01_000001_create_sessions_table.php new file mode 100644 index 0000000..16dff7c --- /dev/null +++ b/database/migrations/2023_01_01_000001_create_sessions_table.php @@ -0,0 +1,32 @@ +string('id')->primary(); + $table->foreignIdFor(User::class)->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/publish/emails/team-invitation.blade.php b/publish/emails/team-invitation.blade.php new file mode 100644 index 0000000..1701212 --- /dev/null +++ b/publish/emails/team-invitation.blade.php @@ -0,0 +1,23 @@ +@component('mail::message') +{{ __('You have been invited to join the :team team!', ['team' => $invitation->team->name]) }} + +@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration())) +{{ __('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:') }} + +@component('mail::button', ['url' => route('register')]) +{{ __('Create Account') }} +@endcomponent + +{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }} + +@else +{{ __('You may accept this invitation by clicking the button below:') }} +@endif + + +@component('mail::button', ['url' => $acceptUrl]) +{{ __('Accept Invitation') }} +@endcomponent + +{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }} +@endcomponent diff --git a/publish/markdown/policy.md b/publish/markdown/policy.md new file mode 100644 index 0000000..f923916 --- /dev/null +++ b/publish/markdown/policy.md @@ -0,0 +1,139 @@ +# Privacy & Cookie Statement + +At Eddy, we place a high value on your privacy. This Privacy & Cookie Statement outlines the personal data we collect, the reasons behind it, how we use your information, and the measures we take to safeguard your privacy. Throughout this document, "Eddy" "we" "us" and "our" all refer to our organization. + +## Purposes + +At Eddy, we process your personal data for a variety of purposes. In the following sections, we provide a detailed explanation of why we process certain types of personal data, the legal basis for doing so, and how long we retain your information. + +1. *Website visit* + +During your visit to our website, we process the following personal data: + +- Your IP address +- Visitor analytics using Fathom's privacy-first analytics +- Language, country, and pricing settings +- Cloudflare DDoS protection and analytics + +This personal data is only kept as long as you visit the website; if you leave the website, this information is removed. The legal basis for processing this personal data is our legitimate interest in maintaining a website that functions properly and provides an optimal user experience. + +1. *Purchasing and using our services* + +When you purchase and use our services via our website, we will process certain personal data, which may require you to create an account. To set up and maintain your account, we will need the following personal data: + +- Name +- (Business) telephone number +- (Business) email address +- Name of your organization +- Invoice & payment details +- Content of correspondence +- Account information (login credentials) + +We require this information as part of our agreement with you and to maintain the relationship resulting from this agreement. We will store this information for the duration of our agreement, and certain information may be retained for a longer period if required by law (such as the legal tax retention period of seven years). + +3. *Contact* + +We provide multiple channels for contacting us, including phone, email, and our contact form. To respond to your inquiries through these channels, we will process the following personal data: + +- Name +- (Business) email address +- (Business) telephone number +- Name of your organization +- Any additional information you provide to us in your message + +We will use this information to effectively handle your request and fulfill our obligations under our agreement with you. We will retain this information until we determine that you are satisfied with our response. + +4. *Newsletter* + +We provide several options for staying up-to-date with the latest news about our company and services, such as subscribing to our newsletter. To send you our newsletter, we will only process your (business) email address, and we will use this information for as long as you remain subscribed to our newsletter. + +### Social media and marketing + +We maintain an active presence on various third-party platforms, which we use for marketing purposes. Examples of such platforms include Twitter, LinkedIn, and Google. Please note that we do not share your personal data with these platforms, as we highly value your privacy. Additionally, we do not use services like 'Custom Audiences' and 'Tailored Audiences', which require the sharing of customer relationship management (CRM) data. + +### Anonymization + +We may choose to anonymize certain personal data, which involves removing any identifying information so that the data can no longer be attributed to a specific individual. This anonymized data no longer qualifies as personal data and poses no privacy risks. + +We have a legitimate interest in anonymizing data, as it enables us to conduct statistical research and improve our website without compromising your privacy. + +## Third parties + +As allowed by law and in accordance with our privacy statement, Eddy may engage third-party providers to deliver certain services. These third-party providers are authorized to use your personal data only for the purposes specified in our privacy statement. Eddy has taken all necessary technical and organizational measures to ensure that your personal data is only used by these third-party providers in accordance with our instructions and for the intended purposes. + +Eddy collaborates with several third parties: + +1. Eddy uses OAuth2 to allow users to log in to our platform using their Git account credentials. If you choose to connect your Git account and explicitly agree to the terms and scope specified when connecting the OAuth2 provider, Eddy may also access your public and private repositories on the following Git providers: + +- Github (USA) + +2. Eddy uses an API token and/or username-password combination to interface with several Cloud Providers, but only if the customer chooses to deploy a virtual private server to the selected Cloud Provider. The Cloud Providers we work with for this purpose are: + +- Digital Ocean (USA) +- Hetzner Cloud (Germany) + +3. Eddy uses the following payment providers for processing payments made by paying customers. Please note that this only applies to paying customers and not to users on the free trial: + +- Paddle (see ) + +4. We utilize Cloudflare's reverse proxy as a measure of protection against DDoS attacks, which applies to all visitors. This falls under the necessary category since it is crucial in safeguarding our deployment infrastructure. + +5. Our application and SaaS are hosted on servers located at Hetzner, Germany. Their privacy policy, which can be found at , provides more information about their security protocols. + +## Transfer of personal data + +Eddy and her (sub-)processors may transfer personal data outside the European Economic Area (EEA) insofar such transfer complies with the applicable privacy legislation, such as the GDPR. Transfer of personal data to companies outside of the EEA depends on which of our services you are using. Please see the information above or contact us if you have any questions. + +## Cookies + +We use functional and analytical cookies to optimize your experience on our website. Functional cookies are necessary for logging into our SaaS service, Eddy. Our analytical tools do not use cookies to track users. Please visit the Fathom website for more information, and refer to [Fathom's privacy policy](https://usefathom.com/privacy) for information on the privacy policies of our analytics solutions. + +We retain this personal data for the duration of your website visit and delete it when you leave. We process this data based on our legitimate interest in enhancing and improving our website and services. + +**Necessary / Functional Cookies** + +These cookies are necessary for a properly functioning website and do not require an opt in. + +| Name | Provider | Purpose | Retention | Type | +| ----- | -------- | ------- | ---------- | ---- | +| eddy_session | Eddy | Login status | 2 hours | Necessary | +| remember_web | Eddy| Login status | 5 years | Necessary | +| XSRF-TOKEN | Eddy | CSRF protection | 2 hours | Necessary | +| paddlejs_campaign_referrer | Paddle | Paddle Checkout | 1 week | Necessary | + +## Rights + +We respect your rights under the GDPR and your rights may include the following: + +- The right to access +- The right to correct and supplement +- The right to be forgotten +- The right to data portability +- The right to restriction of processing +- The right to object to automated decision-making and profiling +- The right to object to data processing + +To exercise your rights or if you have any questions about the way we process your personal data, please use the contact information provided at the end of this privacy statement. + +We take your feedback, requests, and complaints seriously and will make every effort to handle them properly. If you are not satisfied with the handling of your request and/or complaint, you have the right to file a complaint with the national authority responsible for supervising compliance with the GDPR. In the Netherlands, this authority is the [Autoriteit Persoonsgegevens](https://www.autoriteitpersoonsgegevens.nl/). + +## Security measures + +Eddy has implemented various technical and organizational security measures to ensure the safety of your personal data. These security measures aim to prevent loss, abuse, unauthorized access or modification. Here are some examples: + +- We use TLS (Transport Layer Security) technology to protect the transmission of personal data through all online channels. +- All equipment is password-protected. +- Access to personal data is limited to a need-to-know basis. +- We strive to comply with standard security norms related to our specific services. +- We use a secure and properly certified hosting provider, and you can find more information about their system policies on Hetzner's website: . + +## Contact details + +If you have any questions or concerns about how we process your personal data at Eddy, you can reach out to us using the following contact details: + +- Website: +- E-mail: info@eddy.management + +## Changes to the Privacy & Cookie Statement + +Eddy may update this Privacy & Cookie Statement from time to time. The latest version of this document was last modified on April 18, 2023. Any changes made to this statement will be posted on this website. It is recommended that you review this statement periodically to stay informed about how we are protecting your personal data. diff --git a/publish/markdown/terms.md b/publish/markdown/terms.md new file mode 100644 index 0000000..2b1a7e0 --- /dev/null +++ b/publish/markdown/terms.md @@ -0,0 +1,5 @@ +# Terms of Service + +Eddy, also known as "Eddy Server Management", is a product of [Protone Media B.V.](https://protone.media) + +All offers, order acceptances, agreements, and communications are subject to the NLdigital Voorwaarden, which have been filed at the Rechtbank Midden-Nederland in Utrecht. The NLdigital Voorwaarden can be viewed below and downloaded in PDF format. Most computers and mobile devices have a built-in PDF reader, but you can also choose to use free software like Adobe Acrobat Reader. diff --git a/resources/markdown/policy.md b/resources/markdown/policy.md new file mode 100644 index 0000000..f923916 --- /dev/null +++ b/resources/markdown/policy.md @@ -0,0 +1,139 @@ +# Privacy & Cookie Statement + +At Eddy, we place a high value on your privacy. This Privacy & Cookie Statement outlines the personal data we collect, the reasons behind it, how we use your information, and the measures we take to safeguard your privacy. Throughout this document, "Eddy" "we" "us" and "our" all refer to our organization. + +## Purposes + +At Eddy, we process your personal data for a variety of purposes. In the following sections, we provide a detailed explanation of why we process certain types of personal data, the legal basis for doing so, and how long we retain your information. + +1. *Website visit* + +During your visit to our website, we process the following personal data: + +- Your IP address +- Visitor analytics using Fathom's privacy-first analytics +- Language, country, and pricing settings +- Cloudflare DDoS protection and analytics + +This personal data is only kept as long as you visit the website; if you leave the website, this information is removed. The legal basis for processing this personal data is our legitimate interest in maintaining a website that functions properly and provides an optimal user experience. + +1. *Purchasing and using our services* + +When you purchase and use our services via our website, we will process certain personal data, which may require you to create an account. To set up and maintain your account, we will need the following personal data: + +- Name +- (Business) telephone number +- (Business) email address +- Name of your organization +- Invoice & payment details +- Content of correspondence +- Account information (login credentials) + +We require this information as part of our agreement with you and to maintain the relationship resulting from this agreement. We will store this information for the duration of our agreement, and certain information may be retained for a longer period if required by law (such as the legal tax retention period of seven years). + +3. *Contact* + +We provide multiple channels for contacting us, including phone, email, and our contact form. To respond to your inquiries through these channels, we will process the following personal data: + +- Name +- (Business) email address +- (Business) telephone number +- Name of your organization +- Any additional information you provide to us in your message + +We will use this information to effectively handle your request and fulfill our obligations under our agreement with you. We will retain this information until we determine that you are satisfied with our response. + +4. *Newsletter* + +We provide several options for staying up-to-date with the latest news about our company and services, such as subscribing to our newsletter. To send you our newsletter, we will only process your (business) email address, and we will use this information for as long as you remain subscribed to our newsletter. + +### Social media and marketing + +We maintain an active presence on various third-party platforms, which we use for marketing purposes. Examples of such platforms include Twitter, LinkedIn, and Google. Please note that we do not share your personal data with these platforms, as we highly value your privacy. Additionally, we do not use services like 'Custom Audiences' and 'Tailored Audiences', which require the sharing of customer relationship management (CRM) data. + +### Anonymization + +We may choose to anonymize certain personal data, which involves removing any identifying information so that the data can no longer be attributed to a specific individual. This anonymized data no longer qualifies as personal data and poses no privacy risks. + +We have a legitimate interest in anonymizing data, as it enables us to conduct statistical research and improve our website without compromising your privacy. + +## Third parties + +As allowed by law and in accordance with our privacy statement, Eddy may engage third-party providers to deliver certain services. These third-party providers are authorized to use your personal data only for the purposes specified in our privacy statement. Eddy has taken all necessary technical and organizational measures to ensure that your personal data is only used by these third-party providers in accordance with our instructions and for the intended purposes. + +Eddy collaborates with several third parties: + +1. Eddy uses OAuth2 to allow users to log in to our platform using their Git account credentials. If you choose to connect your Git account and explicitly agree to the terms and scope specified when connecting the OAuth2 provider, Eddy may also access your public and private repositories on the following Git providers: + +- Github (USA) + +2. Eddy uses an API token and/or username-password combination to interface with several Cloud Providers, but only if the customer chooses to deploy a virtual private server to the selected Cloud Provider. The Cloud Providers we work with for this purpose are: + +- Digital Ocean (USA) +- Hetzner Cloud (Germany) + +3. Eddy uses the following payment providers for processing payments made by paying customers. Please note that this only applies to paying customers and not to users on the free trial: + +- Paddle (see ) + +4. We utilize Cloudflare's reverse proxy as a measure of protection against DDoS attacks, which applies to all visitors. This falls under the necessary category since it is crucial in safeguarding our deployment infrastructure. + +5. Our application and SaaS are hosted on servers located at Hetzner, Germany. Their privacy policy, which can be found at , provides more information about their security protocols. + +## Transfer of personal data + +Eddy and her (sub-)processors may transfer personal data outside the European Economic Area (EEA) insofar such transfer complies with the applicable privacy legislation, such as the GDPR. Transfer of personal data to companies outside of the EEA depends on which of our services you are using. Please see the information above or contact us if you have any questions. + +## Cookies + +We use functional and analytical cookies to optimize your experience on our website. Functional cookies are necessary for logging into our SaaS service, Eddy. Our analytical tools do not use cookies to track users. Please visit the Fathom website for more information, and refer to [Fathom's privacy policy](https://usefathom.com/privacy) for information on the privacy policies of our analytics solutions. + +We retain this personal data for the duration of your website visit and delete it when you leave. We process this data based on our legitimate interest in enhancing and improving our website and services. + +**Necessary / Functional Cookies** + +These cookies are necessary for a properly functioning website and do not require an opt in. + +| Name | Provider | Purpose | Retention | Type | +| ----- | -------- | ------- | ---------- | ---- | +| eddy_session | Eddy | Login status | 2 hours | Necessary | +| remember_web | Eddy| Login status | 5 years | Necessary | +| XSRF-TOKEN | Eddy | CSRF protection | 2 hours | Necessary | +| paddlejs_campaign_referrer | Paddle | Paddle Checkout | 1 week | Necessary | + +## Rights + +We respect your rights under the GDPR and your rights may include the following: + +- The right to access +- The right to correct and supplement +- The right to be forgotten +- The right to data portability +- The right to restriction of processing +- The right to object to automated decision-making and profiling +- The right to object to data processing + +To exercise your rights or if you have any questions about the way we process your personal data, please use the contact information provided at the end of this privacy statement. + +We take your feedback, requests, and complaints seriously and will make every effort to handle them properly. If you are not satisfied with the handling of your request and/or complaint, you have the right to file a complaint with the national authority responsible for supervising compliance with the GDPR. In the Netherlands, this authority is the [Autoriteit Persoonsgegevens](https://www.autoriteitpersoonsgegevens.nl/). + +## Security measures + +Eddy has implemented various technical and organizational security measures to ensure the safety of your personal data. These security measures aim to prevent loss, abuse, unauthorized access or modification. Here are some examples: + +- We use TLS (Transport Layer Security) technology to protect the transmission of personal data through all online channels. +- All equipment is password-protected. +- Access to personal data is limited to a need-to-know basis. +- We strive to comply with standard security norms related to our specific services. +- We use a secure and properly certified hosting provider, and you can find more information about their system policies on Hetzner's website: . + +## Contact details + +If you have any questions or concerns about how we process your personal data at Eddy, you can reach out to us using the following contact details: + +- Website: +- E-mail: info@eddy.management + +## Changes to the Privacy & Cookie Statement + +Eddy may update this Privacy & Cookie Statement from time to time. The latest version of this document was last modified on April 18, 2023. Any changes made to this statement will be posted on this website. It is recommended that you review this statement periodically to stay informed about how we are protecting your personal data. diff --git a/resources/markdown/terms.md b/resources/markdown/terms.md new file mode 100644 index 0000000..2b1a7e0 --- /dev/null +++ b/resources/markdown/terms.md @@ -0,0 +1,5 @@ +# Terms of Service + +Eddy, also known as "Eddy Server Management", is a product of [Protone Media B.V.](https://protone.media) + +All offers, order acceptances, agreements, and communications are subject to the NLdigital Voorwaarden, which have been filed at the Rechtbank Midden-Nederland in Utrecht. The NLdigital Voorwaarden can be viewed below and downloaded in PDF format. Most computers and mobile devices have a built-in PDF reader, but you can also choose to use free software like Adobe Acrobat Reader. diff --git a/resources/views/api/edit.blade.php b/resources/views/api/edit.blade.php new file mode 100644 index 0000000..26fef00 --- /dev/null +++ b/resources/views/api/edit.blade.php @@ -0,0 +1,19 @@ + + + + {{ __('API Token Permissions') }} + + + + + +
+ + +
+
+
diff --git a/resources/views/api/index.blade.php b/resources/views/api/index.blade.php new file mode 100644 index 0000000..11d69e6 --- /dev/null +++ b/resources/views/api/index.blade.php @@ -0,0 +1,140 @@ + + + {{ __('API Tokens') }} + + +
+
+
+
+
+
+

+ {{ __('Create API Token') }} +

+ +

+ {{ __('API tokens allow third-party services to authenticate with our application on your behalf.') }} +

+
+ + + +
+ +
+ + + @if(count($availablePermissions) > 0) +
+ +
+ @endif + +

{{ trans('tomato-admin::global.saved') }}

+ + +
+ +
+ +
+
+ @if(count($tokens) > 0) +
+
+
+
+

+ {{ __('Manage API Tokens') }} +

+ +

+ {{ __('You may delete any of your existing tokens if they are no longer needed.') }} +

+
+ +
+ @foreach($tokens as $token) +
+
+ {{ $token['name'] }} +
+ +
+ @if($token['last_used_ago']) +
+ {{ __('Last used') }} {{ $token['last_used_ago'] }} +
+ @endif + + @if(count($availablePermissions) > 0) + + {{ __('Permissions') }} + + @endif + + + + + +
+
+ @endforeach +
+
+
+
+ @endif +
+
+ + @if($newToken = session('flash.token')) + + + {{ __('API Token') }} + + +
+ {{ __('Please copy your new API token. For your security, it won\'t be shown again.') }} +
+ +
+
+
+ {{ $newToken }} +
+
+ + + +
+
+ + + {{ __('Cancel') }} + +
+
+ + $splade.openPreloadedModal('token-modal') + @endif +
diff --git a/resources/views/auth/forgot-password.blade.php b/resources/views/auth/forgot-password.blade.php index fcf2be0..5b8a17f 100644 --- a/resources/views/auth/forgot-password.blade.php +++ b/resources/views/auth/forgot-password.blade.php @@ -5,7 +5,7 @@ - + diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index d1f2aab..49de4e3 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -3,9 +3,9 @@ {{ __('Login') }} - +
- +
@@ -14,7 +14,7 @@
@if (Route::has('password.request')) - + {{ __('Forgot your password?') }} @endif diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index 2388fca..0fb627d 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -2,14 +2,24 @@ {{trans('tomato-admin::global.auth.register')}} - + + @if(\Laravel\Jetstream\Jetstream::hasTermsAndPrivacyPolicyFeature()) + + {!! __('I agree to the :terms_of_service and :privacy_policy', [ + 'terms_of_service' => ''.__('Terms of Service').'', + 'privacy_policy' => ''.__('Privacy Policy').'', + ]) !!} + + @endif + +
- + {{ trans('tomato-admin::global.auth.already-registered') }} diff --git a/resources/views/auth/two-factor-challenge.blade.php b/resources/views/auth/two-factor-challenge.blade.php new file mode 100644 index 0000000..dc08ada --- /dev/null +++ b/resources/views/auth/two-factor-challenge.blade.php @@ -0,0 +1,46 @@ + + + {{ __('Two-factor Confirmation') }} + + + +
+ +
+

+ {{ __('Please confirm access to your account by entering the authentication code provided by your authenticator application.') }} +

+ +

+ {{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }} +

+
+ + +
+ +
+ +
+ +
+ +
+ + + +
+
+ +
+
+
+ diff --git a/resources/views/components/application-logo.blade.php b/resources/views/components/application-logo.blade.php new file mode 100644 index 0000000..9a7ebcb --- /dev/null +++ b/resources/views/components/application-logo.blade.php @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php new file mode 100644 index 0000000..de43014 --- /dev/null +++ b/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,3 @@ + +
class('font-medium text-sm text-green-600') }} /> + diff --git a/resources/views/components/clipboard.blade.php b/resources/views/components/clipboard.blade.php index af3a00c..4656b06 100644 --- a/resources/views/components/clipboard.blade.php +++ b/resources/views/components/clipboard.blade.php @@ -1,5 +1,12 @@ diff --git a/resources/views/components/dropdown-item.blade.php b/resources/views/components/dropdown-item.blade.php index 9d03202..303c5f7 100644 --- a/resources/views/components/dropdown-item.blade.php +++ b/resources/views/components/dropdown-item.blade.php @@ -1,13 +1,14 @@ @if($type === 'button') - + + - - - - {{trans('tomato-admin::global.dark') }} - + +
+ {{ __('Manage Team') }} +
- -
+ + + @can('create', Laravel\Jetstream\Jetstream::newTeamModel()) + + @endcan +
+ +
+ {{ __('Switch Teams') }} +
+ @foreach(auth()->user()->allTeams() as $team) + + + + @endforeach + +
+ @endif +
+ + +
+ @php + $grav_url = auth()->user()->profile_photo_url; + @endphp + +
+
+
- -
-
- - - - - - {{trans('tomato-admin::global.translation') }} - - -
-
+ +
+ {{ __('Manage Account') }} +
- -
- - - - {{trans('tomato-admin::global.logout')}} - - -
- -
- - + + @if(\Laravel\Jetstream\Jetstream::hasApiFeatures()) + + @endif +
+ + +
+ + +
diff --git a/resources/views/components/row.blade.php b/resources/views/components/row.blade.php index 6a823c4..73e0577 100644 --- a/resources/views/components/row.blade.php +++ b/resources/views/components/row.blade.php @@ -1,12 +1,22 @@ -
+
@if(!isset($table)) -
-
+ @if($inline) +
+
{{$label}} -
-
+ +
+ @else +
+
+ + {{$label}} + +
+
+ @endif @endif
@if($type ==='bool' || !empty($value)) @@ -26,6 +36,33 @@
@endif
+ @elseif($type === 'password') +
+
+ + + + + +
+
+ ********* +
+
+ @elseif($type === 'copy') +
+
+ + + + + + +
+
+ {{ $value }} +
+
@elseif($type === 'badge') @if($href) diff --git a/resources/views/emails/team-invitation.blade.php b/resources/views/emails/team-invitation.blade.php new file mode 100644 index 0000000..1701212 --- /dev/null +++ b/resources/views/emails/team-invitation.blade.php @@ -0,0 +1,23 @@ +@component('mail::message') +{{ __('You have been invited to join the :team team!', ['team' => $invitation->team->name]) }} + +@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration())) +{{ __('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:') }} + +@component('mail::button', ['url' => route('register')]) +{{ __('Create Account') }} +@endcomponent + +{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }} + +@else +{{ __('You may accept this invitation by clicking the button below:') }} +@endif + + +@component('mail::button', ['url' => $acceptUrl]) +{{ __('Accept Invitation') }} +@endcomponent + +{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }} +@endcomponent diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php index f3ad0ed..7162723 100644 --- a/resources/views/layouts/guest.blade.php +++ b/resources/views/layouts/guest.blade.php @@ -2,7 +2,7 @@
- +

@@ -14,4 +14,4 @@

-
\ No newline at end of file +
diff --git a/resources/views/layouts/includes/aside.blade.php b/resources/views/layouts/includes/aside.blade.php index 8db8198..d62c081 100644 --- a/resources/views/layouts/includes/aside.blade.php +++ b/resources/views/layouts/includes/aside.blade.php @@ -56,7 +56,7 @@ class="block w-full font-bold" data-turbo="false" style="" > - + diff --git a/resources/views/layouts/includes/menu.blade.php b/resources/views/layouts/includes/menu.blade.php index 5e0f92c..531210d 100644 --- a/resources/views/layouts/includes/menu.blade.php +++ b/resources/views/layouts/includes/menu.blade.php @@ -33,9 +33,7 @@
@php - $email = auth('web')->user()->email; - $size = 220; - $grav_url = "https://www.gravatar.com/avatar/" . md5( strtolower( trim( $email ) ) ) . "?d=identicon&s=" . $size; + $grav_url = auth()->user()->profile_photo_url; @endphp
diff --git a/resources/views/pages/policy.blade.php b/resources/views/pages/policy.blade.php new file mode 100644 index 0000000..a320ac9 --- /dev/null +++ b/resources/views/pages/policy.blade.php @@ -0,0 +1,13 @@ +@seoTitle(__('Privacy Policy')) + +
+
+
+
+ +
+ + +
+
+
\ No newline at end of file diff --git a/resources/views/pages/terms.blade.php b/resources/views/pages/terms.blade.php new file mode 100644 index 0000000..57231b3 --- /dev/null +++ b/resources/views/pages/terms.blade.php @@ -0,0 +1,14 @@ +@seoTitle(__('Terms of Service')) + +
+
+
+
+ +
+ + + +
+
+
diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php index 6adc1fc..51c1220 100644 --- a/resources/views/profile/edit.blade.php +++ b/resources/views/profile/edit.blade.php @@ -1,7 +1,7 @@ - + {{ trans('tomato-admin::global.profile.index') }} - +
@@ -17,11 +17,27 @@
+ @if(Laravel\Fortify\Features::canManageTwoFactorAuthentication()) +
+
+ @include('tomato-admin::profile.partials.two-factor-authentication-form') +
+
+ @endif +
-
- @include('tomato-admin::profile.partials.delete-user-form') +
+ @include('tomato-admin::profile.partials.logout-other-browser-sessions-form')
+ + @if(Laravel\Jetstream\Jetstream::hasAccountDeletionFeatures()) +
+
+ @include('tomato-admin::profile.partials.delete-user-form') +
+
+ @endif
diff --git a/resources/views/profile/partials/logout-other-browser-sessions-form.blade.php b/resources/views/profile/partials/logout-other-browser-sessions-form.blade.php new file mode 100644 index 0000000..fa34159 --- /dev/null +++ b/resources/views/profile/partials/logout-other-browser-sessions-form.blade.php @@ -0,0 +1,70 @@ +
+
+

+ {{ __('Browser Sessions') }} +

+ +

+ {{ __('Manage and log out your active sessions on other browsers and devices.') }} +

+
+ + +
+ {{ __('If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.') }} +
+ + + @if(count($sessions) > 0) +
+ @foreach($sessions as $session) +
+
+ @if($session->agent['is_desktop']) + + + + @else + + + + @endif +
+ +
+
+ {{ $session->agent['platform'] ?: 'Unknown' }} - {{ $session->agent['browser'] ?: 'Unknown' }} +
+ +
+
+ {{ $session->ip_address }}, + + @if($session->is_current_device) + {{ __('This device') }} + @else + {{ __('Last active') }} {{ $session->last_active }} + @endif +
+
+
+
+ @endforeach +
+ @endif + +
+ + +

{{ __('Done.') }}

+
+
+
diff --git a/resources/views/profile/partials/two-factor-authentication-form.blade.php b/resources/views/profile/partials/two-factor-authentication-form.blade.php new file mode 100644 index 0000000..9cfdd47 --- /dev/null +++ b/resources/views/profile/partials/two-factor-authentication-form.blade.php @@ -0,0 +1,120 @@ +
+
+

+ {{ __('Two Factor Authentication') }} +

+ +

+ {{ __('Add additional security to your account using two factor authentication.') }} +

+
+ + @php + $enabled = !empty(auth()->user()->two_factor_secret); + $showingQrCode = $enabled && !auth()->user()->two_factor_confirmed_at; + $showingConfirmation = $showingQrCode && $confirmsTwoFactorAuthentication; + @endphp + + +

+ @if ($enabled) + @if ($showingConfirmation) + {{ __('Finish enabling two factor authentication.') }} + @else + {{ __('You have enabled two factor authentication.') }} + @endif + @else + {{ __('You have not enabled two factor authentication.') }} + @endif +

+ +
+

+ {{ __('When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\'s Google Authenticator application.') }} +

+
+ + + @if ($enabled) + @if ($showingQrCode) +
+

+ @if ($showingConfirmation) + {{ __('To finish enabling two factor authentication, scan the following QR code using your phone\'s authenticator application or enter the setup key and provide the generated OTP code.') }} + @else + {{ __('Two factor authentication is now enabled. Scan the following QR code using your phone\'s authenticator application or enter the setup key.') }} + @endif +

+
+ +
+ {!! auth()->user()->twoFactorQrCodeSvg() !!} +
+ +
+

+ {{ __('Setup Key') }}: {{ decrypt(auth()->user()->two_factor_secret) }} +

+
+ + @if ($showingConfirmation) + + + + + {{-- This submit button requires a click handler because of the teleport. --}} + + + + @endif + @endif + +
+
+

+ {{ __('Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.') }} +

+
+ +
+ @foreach (auth()->user()->recoveryCodes() as $code) +
{{ $code }}
+ @endforeach +
+
+ @endif + +
+ @if (!$enabled) + + + + @else +
+ @if ($showingConfirmation) +
+ @else + + + + + + + + @endif + + @if ($showingConfirmation) + + + + @else + + + + @endif +
+ @endif +
+ + +
diff --git a/resources/views/profile/partials/update-password-form.blade.php b/resources/views/profile/partials/update-password-form.blade.php index ae9f506..40f994d 100644 --- a/resources/views/profile/partials/update-password-form.blade.php +++ b/resources/views/profile/partials/update-password-form.blade.php @@ -9,7 +9,7 @@

- + @@ -17,9 +17,8 @@
- @if (session('status') === 'password-updated') -

{{ trans('tomato-admin::global.saved') }}

- @endif +

{{ trans('tomato-admin::global.saved') }}

+
diff --git a/resources/views/profile/partials/update-profile-information-form.blade.php b/resources/views/profile/partials/update-profile-information-form.blade.php index e53eee2..4f28b34 100644 --- a/resources/views/profile/partials/update-profile-information-form.blade.php +++ b/resources/views/profile/partials/update-profile-information-form.blade.php @@ -1,44 +1,95 @@

- {{ trans('tomato-admin::global.profile.information') }} + {{ __('Profile Information') }}

- {{ trans('tomato-admin::global.profile.information-description') }} + {{ __('Update your account\'s profile information and email address.') }}

- - - + +
+ + @if(Laravel\Jetstream\Jetstream::managesProfilePhotos()) +
+ {{ __('Photo') }} - @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) -
-

- {{ trans('tomato-admin::global.profile.email-unverified') }} + +

+ {{ auth()->user()->name }} +
- - {{ trans('tomato-admin::global.profile.email-re-send') }} - -

+ +
+ +
+ + +
+ + {{ __('Select A New Photo') }} + + + + @if(auth()->user()->profile_photo_path) + + {{ __('Remove Photo') }} + + @endif + +
+
+ @endif - @if (session('status') === 'verification-link-sent') -

- {{ trans('tomato-admin::global.profile.email-address') }} -

- @endif + +
+
- @endif -
- + +
+ +
+
- @if (session('status') === 'profile-updated') -

- {{ trans('tomato-admin::global.saved') }} -

- @endif +

+ {{ trans('tomato-admin::global.saved') }} +

+ +
+ + @if(Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::emailVerification()) && !auth()->user()->hasVerifiedEmail()) + {{-- This section over here is teleported so we don't have a form in a form. --}} + + +

+ {{ __('Your email address is unverified.') }} + + +

+ +
+ {{ __('A new verification link has been sent to your email address.') }} +
+
+
+ @endif
+ + + diff --git a/resources/views/teams/add-team-member.blade.php b/resources/views/teams/add-team-member.blade.php new file mode 100644 index 0000000..4102db4 --- /dev/null +++ b/resources/views/teams/add-team-member.blade.php @@ -0,0 +1,64 @@ +
+
+

+ {{ __('Team Name') }} +

+ +

+ {{ __('The team\'s name and owner information.') }} +

+
+ + +
+
+ {{ __('Please provide the email address of the person you would like to add to this team.') }} +
+
+ + +
+ +
+ + + @if(count($availableRoles) > 0) +
+ +
+ @foreach($availableRoles as $role) + + @endforeach +
+
+
+ @endif + + + +

{{ trans('tomato-admin::global.saved') }}

+
+
diff --git a/resources/views/teams/create.blade.php b/resources/views/teams/create.blade.php new file mode 100644 index 0000000..088dbe7 --- /dev/null +++ b/resources/views/teams/create.blade.php @@ -0,0 +1,52 @@ + + + {{ __('Create Team') }} + + +
+
+
+
+
+

+ {{ __('Team Details') }} +

+ +

+ {{ __('Create a new team to collaborate with others on projects.') }} +

+
+
+
+ +
+
+ +
+ {{ auth()->user()->name }} + +
+
{{ auth()->user()->name }}
+
+ {{ auth()->user()->email }} +
+
+
+
+
+ +
+ +
+
+ +
+
+
+
+ + +
+
+
+
diff --git a/resources/views/teams/delete-team-form.blade.php b/resources/views/teams/delete-team-form.blade.php new file mode 100644 index 0000000..4b1b035 --- /dev/null +++ b/resources/views/teams/delete-team-form.blade.php @@ -0,0 +1,27 @@ +
+
+

+ {{ __('Delete Team') }} +

+ +

+ {{ __('Permanently delete this team.') }} +

+
+
+ {{ __('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.') }} +
+ +
+ + + +
+ +
diff --git a/resources/views/teams/manage-team-members.blade.php b/resources/views/teams/manage-team-members.blade.php new file mode 100644 index 0000000..0f8008d --- /dev/null +++ b/resources/views/teams/manage-team-members.blade.php @@ -0,0 +1,61 @@ +
+
+

+ {{ __('Team Members') }} +

+ +

+ {{ __('All of the people that are part of this team.') }} +

+
+ +
+ @foreach($team->users as $user) +
+
+ +
+
+ +
+ + @if($permissions['canAddTeamMembers'] && !empty($availablePermissions)) + + {{ collect($availableRoles)->firstWhere('key', $user->membership->role)?->name }} + + @elseif(!empty($availablePermissions)) +
+ {{ collect($availableRoles)->firstWhere('key', $user->membership->role)?->name }} +
+ @endif + + @if(auth()->user()->is($user)) + + + + @elseif($permissions['canRemoveTeamMembers']) + + + + @endif +
+
+ @endforeach +
+
diff --git a/resources/views/teams/member-role-form.blade.php b/resources/views/teams/member-role-form.blade.php new file mode 100644 index 0000000..a450874 --- /dev/null +++ b/resources/views/teams/member-role-form.blade.php @@ -0,0 +1,52 @@ + + + + + {{ __('Manage Role') }} + + + +
+ @foreach($availableRoles as $role) + + @endforeach +
+ + + + + + + +
+
+
\ No newline at end of file diff --git a/resources/views/teams/show.blade.php b/resources/views/teams/show.blade.php new file mode 100644 index 0000000..052b6c8 --- /dev/null +++ b/resources/views/teams/show.blade.php @@ -0,0 +1,49 @@ + + + {{ __('Team Settings') }} + + +
+
+
+
+ @include('tomato-admin::teams.update-team-name-form') +
+
+ + + @if($permissions['canAddTeamMembers']) +
+
+ @include('tomato-admin::teams.add-team-member') +
+
+ @endif + + @if($permissions['canAddTeamMembers'] && $team->teamInvitations->isNotEmpty()) +
+
+ @include('tomato-admin::teams.team-member-invitations') +
+
+ @endif + + @if($team->users->isNotEmpty()) +
+
+ @include('tomato-admin::teams.manage-team-members') +
+
+ @endif + + @if($permissions['canDeleteTeam'] && !$team->personal_team) +
+
+ @include('tomato-admin::teams.delete-team-form') +
+
+ @endif +
+ +
+
diff --git a/resources/views/teams/team-member-invitations.blade.php b/resources/views/teams/team-member-invitations.blade.php new file mode 100644 index 0000000..d7b5782 --- /dev/null +++ b/resources/views/teams/team-member-invitations.blade.php @@ -0,0 +1,38 @@ +
+
+

+ {{ __('Pending Team Invitations') }} +

+ +

+ {{ __('These people have been invited to your team and have been sent an invitation email. They may join the team by accepting the email invitation.') }} +

+
+ +
+
+ @foreach($team->teamInvitations as $invitation) +
+
+ {{ $invitation->email }} +
+ + @if($permissions['canRemoveTeamMembers']) +
+ + + + +
+ @endif +
+ @endforeach +
+
+ +
diff --git a/resources/views/teams/team-member-role-form.blade.php b/resources/views/teams/team-member-role-form.blade.php new file mode 100644 index 0000000..62e044a --- /dev/null +++ b/resources/views/teams/team-member-role-form.blade.php @@ -0,0 +1,41 @@ + + + {{ __('Manage Role') }} + + + + +
+ @foreach($availableRoles as $role) + + @endforeach +
+
+ + + +
+
diff --git a/resources/views/teams/update-team-name-form.blade.php b/resources/views/teams/update-team-name-form.blade.php new file mode 100644 index 0000000..317b0f7 --- /dev/null +++ b/resources/views/teams/update-team-name-form.blade.php @@ -0,0 +1,43 @@ +
+
+

+ {{ __('Team Name') }} +

+ +

+ {{ __('The team\'s name and owner information.') }} +

+
+ + + + +
+ +
+ + +
+
+
+ {{ $team->owner?->email }} +
+
+
+ +
+ + +
+ +
+ + + @if($permissions['canUpdateTeam']) + + +

{{ trans('tomato-admin::global.saved') }}

+ @endif + + +
diff --git a/routes/web.php b/routes/web.php index 843d12e..c63bb81 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,30 +3,71 @@ use Illuminate\Support\Facades\Route; use \TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController; use TomatoPHP\TomatoPHP\Http\Middleware\LanguageSwitcher; +use Laravel\Jetstream\Jetstream; Route::middleware(array_merge(['splade'], config('tomato-admin.route_middlewares')))->prefix(config('tomato-admin.route_perfix'))->name(config('tomato-admin.route_perfix') . '.')->group(function () { - if(config('tomato-admin.register')){ - Route::get('register', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\RegisteredUserController::class, 'create'])->name('register'); - Route::post('register', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\RegisteredUserController::class, 'store']); + if (Jetstream::hasTermsAndPrivacyPolicyFeature()) { + Route::get('/terms-of-service', [\TomatoPHP\TomatoAdmin\Http\Controllers\TermsOfServiceController::class, 'show'])->name('terms.show'); + Route::get('/privacy-policy', [\TomatoPHP\TomatoAdmin\Http\Controllers\PrivacyPolicyController::class, 'show'])->name('policy.show'); } - - Route::get('login', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\AuthenticatedSessionController::class, 'create'])->name('login'); - Route::post('login', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\AuthenticatedSessionController::class, 'store']); - - Route::get('forgot-password', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\PasswordResetLinkController::class, 'create'])->name('password.request'); - Route::post('forgot-password', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\PasswordResetLinkController::class, 'store'])->name('password.email'); - Route::get('reset-password/{token}', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\NewPasswordController::class, 'create'])->name('password.reset'); - Route::post('reset-password', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\NewPasswordController::class, 'store'])->name('password.store'); }); Route::middleware(array_merge(['splade', 'auth'], config('tomato-admin.route_middlewares')))->prefix(config('tomato-admin.route_perfix'))->name(config('tomato-admin.route_perfix'))->group(function (){ + //Dashboard Actions Route::get('/', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'index']); Route::post('/switch', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'switchLang'])->name('.lang'); - Route::get('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'profile'])->name('.profile.edit'); - Route::patch('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'update'])->name('.profile.update'); - Route::put('/profile/password', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'password'])->name('.profile.password'); - Route::delete('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\DashboardController::class, 'destroy'])->name('.profile.destroy'); + + //Profile + Route::get('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\UserProfileController::class, 'show'])->name('.profile.edit'); + Route::get('/profile/teams', [\TomatoPHP\TomatoAdmin\Http\Controllers\UserProfileController::class, 'teams'])->name('.profile.teams'); + Route::patch('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\UserProfileController::class, 'update'])->name('.profile.update'); + Route::put('/profile/password', [\TomatoPHP\TomatoAdmin\Http\Controllers\UserProfileController::class, 'password'])->name('.profile.password'); + Route::delete('/profile', [\TomatoPHP\TomatoAdmin\Http\Controllers\UserProfileController::class, 'destroy'])->name('.profile.destroy'); + + + // User & Profile... + Route::get('/user/profile', [\Laravel\Jetstream\Http\Controllers\Inertia\UserProfileController::class, 'show'])->name('.profile.show'); + Route::delete('/user/other-browser-sessions', [\TomatoPHP\TomatoAdmin\Http\Controllers\OtherBrowserSessionsController::class, 'destroy'])->name('.other-browser-sessions.destroy'); + Route::delete('/user/profile-photo', [\TomatoPHP\TomatoAdmin\Http\Controllers\ProfilePhotoController::class, 'destroy'])->name('.current-user-photo.destroy'); + + if (Jetstream::hasAccountDeletionFeatures()) { + Route::delete('/user', [\TomatoPHP\TomatoAdmin\Http\Controllers\CurrentTeamController::class, 'destroy']) + ->name('.current-user.destroy'); + } + + Route::group(['middleware' => 'verified'], function () { + // API... + if (Jetstream::hasApiFeatures()) { + Route::get('/user/api-tokens', [\TomatoPHP\TomatoAdmin\Http\Controllers\ApiTokenController::class, 'index'])->name('.api-tokens.index'); + Route::post('/user/api-tokens', [\TomatoPHP\TomatoAdmin\Http\Controllers\ApiTokenController::class, 'store'])->name('.api-tokens.store'); + Route::get('/user/api-tokens/{token}', [\TomatoPHP\TomatoAdmin\Http\Controllers\ApiTokenController::class, 'edit'])->name('.api-tokens.edit'); + Route::put('/user/api-tokens/{token}', [\TomatoPHP\TomatoAdmin\Http\Controllers\ApiTokenController::class, 'update'])->name('.api-tokens.update'); + Route::delete('/user/api-tokens/{token}', [\TomatoPHP\TomatoAdmin\Http\Controllers\ApiTokenController::class, 'destroy'])->name('.api-tokens.destroy'); + } + + // Teams... + if (Jetstream::hasTeamFeatures()) { + Route::get('/teams/create', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamController::class, 'create'])->name('.teams.create'); + Route::post('/teams', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamController::class, 'store'])->name('.teams.store'); + Route::get('/teams/{team}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamController::class, 'show'])->name('.teams.show'); + Route::put('/teams/{team}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamController::class, 'update'])->name('.teams.update'); + Route::delete('/teams/{team}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamController::class, 'destroy'])->name('.teams.destroy'); + Route::put('/current-team', [\TomatoPHP\TomatoAdmin\Http\Controllers\CurrentTeamController::class, 'update'])->name('.current-team.update'); + Route::post('/teams/{team}/members', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamMemberController::class, 'store'])->name('.team-members.store'); + Route::get('/teams/{team}/members/{user}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamMemberController::class, 'edit'])->name('.team-members.edit'); + Route::put('/teams/{team}/members/{user}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamMemberController::class, 'update'])->name('.team-members.update'); + Route::delete('/teams/{team}/members/{user}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamMemberController::class, 'destroy'])->name('.team-members.destroy'); + + Route::get('/team-invitations/{invitation}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamInvitationController::class, 'accept']) + ->middleware(['signed']) + ->name('.team-invitations.accept'); + + Route::delete('/team-invitations/{invitation}', [\TomatoPHP\TomatoAdmin\Http\Controllers\TeamInvitationController::class, 'destroy']) + ->name('.team-invitations.destroy'); + } + }); + Route::get('verify-email', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\EmailVerificationPromptController::class, '__invoke'])->name('verification.notice'); Route::get('verify-email/{id}/{hash}', [\TomatoPHP\TomatoAdmin\Http\Controllers\Auth\VerifyEmailController::class, '__invoke'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); diff --git a/src/Actions/Fortify/CreateNewUser.php b/src/Actions/Fortify/CreateNewUser.php new file mode 100644 index 0000000..252bbde --- /dev/null +++ b/src/Actions/Fortify/CreateNewUser.php @@ -0,0 +1,56 @@ + $input + */ + public function create(array $input): User + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => $this->passwordRules(), + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + ])->validate(); + + return DB::transaction(function () use ($input) { + return tap(User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]), function (User $user) { + $this->createTeam($user); + }); + }); + } + + /** + * Create a personal team for the user. + */ + protected function createTeam(User $user): void + { + $user->ownedTeams()->save(Team::forceCreate([ + 'user_id' => $user->id, + 'name' => explode(' ', $user->name, 2)[0]."'s Team", + 'personal_team' => true, + ])); + } +} diff --git a/src/Actions/Fortify/CreateNewUserWithTeams.php b/src/Actions/Fortify/CreateNewUserWithTeams.php new file mode 100644 index 0000000..f67940b --- /dev/null +++ b/src/Actions/Fortify/CreateNewUserWithTeams.php @@ -0,0 +1,53 @@ + $input + */ + public function create(array $input): User + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'password' => $this->passwordRules(), + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', + ])->validate(); + + return DB::transaction(function () use ($input) { + return tap(User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]), function (User $user) { + $this->createTeam($user); + }); + }); + } + + /** + * Create a personal team for the user. + */ + protected function createTeam(User $user): void + { + $user->ownedTeams()->save(Team::forceCreate([ + 'user_id' => $user->id, + 'name' => explode(' ', $user->name, 2)[0]."'s Team", + 'personal_team' => true, + ])); + } +} diff --git a/src/Actions/Fortify/PasswordValidationRules.php b/src/Actions/Fortify/PasswordValidationRules.php new file mode 100644 index 0000000..4dd3a86 --- /dev/null +++ b/src/Actions/Fortify/PasswordValidationRules.php @@ -0,0 +1,21 @@ + + */ + protected function passwordRules(): array + { + return ['required', 'string', new Password, 'confirmed']; + } +} diff --git a/src/Actions/Fortify/ResetUserPassword.php b/src/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 0000000..278a3b9 --- /dev/null +++ b/src/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,32 @@ + $input + */ + public function reset(User $user, array $input): void + { + Validator::make($input, [ + 'password' => $this->passwordRules(), + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/src/Actions/Fortify/UpdateUserPassword.php b/src/Actions/Fortify/UpdateUserPassword.php new file mode 100644 index 0000000..aaf2975 --- /dev/null +++ b/src/Actions/Fortify/UpdateUserPassword.php @@ -0,0 +1,35 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'current_password' => ['required', 'string', 'current_password:web'], + 'password' => $this->passwordRules(), + ], [ + 'current_password.current_password' => __('The provided password does not match your current password.'), + ])->validateWithBag('updatePassword'); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/src/Actions/Fortify/UpdateUserProfileInformation.php b/src/Actions/Fortify/UpdateUserProfileInformation.php new file mode 100644 index 0000000..575b750 --- /dev/null +++ b/src/Actions/Fortify/UpdateUserProfileInformation.php @@ -0,0 +1,59 @@ + $input + */ + public function update(User $user, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], + 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + ])->validateWithBag('updateProfileInformation'); + + if (isset($input['photo'])) { + $user->updateProfilePhoto($input['photo']); + } + + if ($input['email'] !== $user->email && + $user instanceof MustVerifyEmail) { + $this->updateVerifiedUser($user, $input); + } else { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + ])->save(); + } + } + + /** + * Update the given verified user's profile information. + * + * @param array $input + */ + protected function updateVerifiedUser(User $user, array $input): void + { + $user->forceFill([ + 'name' => $input['name'], + 'email' => $input['email'], + 'email_verified_at' => null, + ])->save(); + + $user->sendEmailVerificationNotification(); + } +} diff --git a/src/Actions/Jetstream/AddTeamMember.php b/src/Actions/Jetstream/AddTeamMember.php new file mode 100644 index 0000000..341fe5e --- /dev/null +++ b/src/Actions/Jetstream/AddTeamMember.php @@ -0,0 +1,81 @@ +authorize('addTeamMember', $team); + + $this->validate($team, $email, $role); + + $newTeamMember = Jetstream::findUserByEmailOrFail($email); + + AddingTeamMember::dispatch($team, $newTeamMember); + + $team->users()->attach( + $newTeamMember, ['role' => $role] + ); + + TeamMemberAdded::dispatch($team, $newTeamMember); + } + + /** + * Validate the add member operation. + */ + protected function validate(Team $team, string $email, ?string $role): void + { + Validator::make([ + 'email' => $email, + 'role' => $role, + ], $this->rules(), [ + 'email.exists' => __('We were unable to find a registered user with this email address.'), + ])->after( + $this->ensureUserIsNotAlreadyOnTeam($team, $email) + )->validateWithBag('addTeamMember'); + } + + /** + * Get the validation rules for adding a team member. + * + * @return array + */ + protected function rules(): array + { + return array_filter([ + 'email' => ['required', 'email', 'exists:users'], + 'role' => Jetstream::hasRoles() + ? ['required', 'string', new Role] + : null, + ]); + } + + /** + * Ensure that the user is not already on the team. + */ + protected function ensureUserIsNotAlreadyOnTeam(Team $team, string $email): Closure + { + return function ($validator) use ($team, $email) { + $validator->errors()->addIf( + $team->hasUserWithEmail($email), + 'email', + __('This user already belongs to the team.') + ); + }; + } +} diff --git a/src/Actions/Jetstream/CreateTeam.php b/src/Actions/Jetstream/CreateTeam.php new file mode 100644 index 0000000..ce61794 --- /dev/null +++ b/src/Actions/Jetstream/CreateTeam.php @@ -0,0 +1,37 @@ + $input + */ + public function create(User $user, array $input): Team + { + Gate::forUser($user)->authorize('create', Jetstream::newTeamModel()); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + ])->validateWithBag('createTeam'); + + AddingTeam::dispatch($user); + + $user->switchTeam($team = $user->ownedTeams()->create([ + 'name' => $input['name'], + 'personal_team' => false, + ])); + + return $team; + } +} diff --git a/src/Actions/Jetstream/DeleteTeam.php b/src/Actions/Jetstream/DeleteTeam.php new file mode 100644 index 0000000..b82b9ca --- /dev/null +++ b/src/Actions/Jetstream/DeleteTeam.php @@ -0,0 +1,17 @@ +purge(); + } +} diff --git a/src/Actions/Jetstream/DeleteUser.php b/src/Actions/Jetstream/DeleteUser.php new file mode 100644 index 0000000..2bc3230 --- /dev/null +++ b/src/Actions/Jetstream/DeleteUser.php @@ -0,0 +1,19 @@ +deleteProfilePhoto(); + $user->tokens->each->delete(); + $user->delete(); + } +} diff --git a/src/Actions/Jetstream/DeleteUserWithTeams.php b/src/Actions/Jetstream/DeleteUserWithTeams.php new file mode 100644 index 0000000..f8a92dc --- /dev/null +++ b/src/Actions/Jetstream/DeleteUserWithTeams.php @@ -0,0 +1,52 @@ +deletesTeams = $deletesTeams; + } + + /** + * Delete the given user. + */ + public function delete(User $user): void + { + DB::transaction(function () use ($user) { + $this->deleteTeams($user); + $user->deleteProfilePhoto(); + $user->tokens->each->delete(); + $user->delete(); + }); + } + + /** + * Delete the teams and team associations attached to the user. + */ + protected function deleteTeams(User $user): void + { + $user->teams()->detach(); + + $user->ownedTeams->each(function (Team $team) { + $this->deletesTeams->delete($team); + }); + } +} diff --git a/src/Actions/Jetstream/InviteTeamMember.php b/src/Actions/Jetstream/InviteTeamMember.php new file mode 100644 index 0000000..eb16e28 --- /dev/null +++ b/src/Actions/Jetstream/InviteTeamMember.php @@ -0,0 +1,88 @@ +authorize('addTeamMember', $team); + + $this->validate($team, $email, $role); + + InvitingTeamMember::dispatch($team, $email, $role); + + $invitation = $team->teamInvitations()->create([ + 'email' => $email, + 'role' => $role, + ]); + + Mail::to($email)->send(new TeamInvitation($invitation)); + } + + /** + * Validate the invite member operation. + */ + protected function validate(Team $team, string $email, ?string $role): void + { + Validator::make([ + 'email' => $email, + 'role' => $role, + ], $this->rules($team), [ + 'email.unique' => __('This user has already been invited to the team.'), + ])->after( + $this->ensureUserIsNotAlreadyOnTeam($team, $email) + )->validateWithBag('addTeamMember'); + } + + /** + * Get the validation rules for inviting a team member. + * + * @return array + */ + protected function rules(Team $team): array + { + return array_filter([ + 'email' => [ + 'required', 'email', + Rule::unique('team_invitations')->where(function (Builder $query) use ($team) { + $query->where('team_id', $team->id); + }), + ], + 'role' => Jetstream::hasRoles() + ? ['required', 'string', new Role] + : null, + ]); + } + + /** + * Ensure that the user is not already on the team. + */ + protected function ensureUserIsNotAlreadyOnTeam(Team $team, string $email): Closure + { + return function ($validator) use ($team, $email) { + $validator->errors()->addIf( + $team->hasUserWithEmail($email), + 'email', + __('This user already belongs to the team.') + ); + }; + } +} diff --git a/src/Actions/Jetstream/RemoveTeamMember.php b/src/Actions/Jetstream/RemoveTeamMember.php new file mode 100644 index 0000000..156bf2a --- /dev/null +++ b/src/Actions/Jetstream/RemoveTeamMember.php @@ -0,0 +1,51 @@ +authorize($user, $team, $teamMember); + + $this->ensureUserDoesNotOwnTeam($teamMember, $team); + + $team->removeUser($teamMember); + + TeamMemberRemoved::dispatch($team, $teamMember); + } + + /** + * Authorize that the user can remove the team member. + */ + protected function authorize(User $user, Team $team, User $teamMember): void + { + if (! Gate::forUser($user)->check('removeTeamMember', $team) && + $user->id !== $teamMember->id) { + throw new AuthorizationException; + } + } + + /** + * Ensure that the currently authenticated user does not own the team. + */ + protected function ensureUserDoesNotOwnTeam(User $teamMember, Team $team): void + { + if ($teamMember->id === $team->owner->id) { + throw ValidationException::withMessages([ + 'team' => [__('You may not leave a team that you created.')], + ])->errorBag('removeTeamMember'); + } + } +} diff --git a/src/Actions/Jetstream/UpdateTeamName.php b/src/Actions/Jetstream/UpdateTeamName.php new file mode 100644 index 0000000..9813d7e --- /dev/null +++ b/src/Actions/Jetstream/UpdateTeamName.php @@ -0,0 +1,30 @@ + $input + */ + public function update(User $user, Team $team, array $input): void + { + Gate::forUser($user)->authorize('update', $team); + + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + ])->validateWithBag('updateTeamName'); + + $team->forceFill([ + 'name' => $input['name'], + ])->save(); + } +} diff --git a/src/Console/TomatoAdminInstall.php b/src/Console/TomatoAdminInstall.php index 7b144ff..da06bbf 100644 --- a/src/Console/TomatoAdminInstall.php +++ b/src/Console/TomatoAdminInstall.php @@ -43,13 +43,14 @@ public function handle(): void { $this->info('🍅 Install Splade / Breeze UI ...'); $this->artisanCommand(["splade:install"]); - $this->artisanCommand(["breeze:install"]); $this->info('🍅 Publish Files ...'); $this->handelFile('/tailwind.config.js', base_path('/tailwind.config.js')); $this->handelFile('/vite.config.js', base_path('/vite.config.js')); $this->handelFile('/package.json', base_path('/package.json')); $this->handelFile('/resources/js', resource_path('/js'), 'folder'); $this->handelFile('/resources/css', resource_path('/css'), 'folder'); + $this->handelFile('/markdown', resource_path('/'), 'folder'); + $this->handelFile('/emails', resource_path('/views'), 'folder'); $this->call('vendor:publish', [ "--provider" => "Spatie\MediaLibraryPro\MediaLibraryProServiceProvider", ]); diff --git a/src/Http/Controllers/ApiTokenController.php b/src/Http/Controllers/ApiTokenController.php new file mode 100644 index 0000000..c009ba7 --- /dev/null +++ b/src/Http/Controllers/ApiTokenController.php @@ -0,0 +1,105 @@ + $request->user()->tokens->map(function ($token) { + return $token->toArray() + [ + 'last_used_ago' => optional($token->last_used_at)->diffForHumans(), + ]; + }), + 'availablePermissions' => Jetstream::$permissions, + 'defaultPermissions' => Jetstream::$defaultPermissions, + ]); + } + + /** + * Create a new API token. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request) + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $token = $request->user()->createToken( + $request->name, + Jetstream::validPermissions($request->input('permissions', [])) + ); + + return back()->with('flash', [ + 'token' => explode('|', $token->plainTextToken, 2)[1], + ]); + } + + /** + * Edit the given API token's permissions. + * + * @param \Illuminate\Http\Request $request + * @param string $tokenId + * @return \Illuminate\Http\RedirectResponse + */ + public function edit(Request $request, $tokenId) + { + $token = $request->user()->tokens()->where('id', $tokenId)->firstOrFail(); + + return view('tomato-admin::api.edit', [ + 'token' => $token, + 'availablePermissions' => Jetstream::$permissions, + ]); + } + + /** + * Update the given API token's permissions. + * + * @param \Illuminate\Http\Request $request + * @param string $tokenId + * @return \Illuminate\Http\RedirectResponse + */ + public function update(Request $request, $tokenId) + { + $request->validate([ + 'permissions' => 'array', + 'permissions.*' => 'string', + ]); + + $token = $request->user()->tokens()->where('id', $tokenId)->firstOrFail(); + + $token->forceFill([ + 'abilities' => Jetstream::validPermissions($request->input('permissions', [])), + ])->save(); + + return back(303); + } + + /** + * Delete the given API token. + * + * @param \Illuminate\Http\Request $request + * @param string $tokenId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, $tokenId) + { + $request->user()->tokens()->where('id', $tokenId)->first()->delete(); + + return back(303); + } +} diff --git a/src/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php b/src/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php new file mode 100644 index 0000000..065b9a7 --- /dev/null +++ b/src/Http/Controllers/Concerns/ConfirmsTwoFactorAuthentication.php @@ -0,0 +1,84 @@ +twoFactorAuthenticationDisabled($request)) { + $request->session()->put('two_factor_empty_at', $currentTime); + } + + // If was previously totally disabled this session but is now confirming, notate time... + if ($this->hasJustBegunConfirmingTwoFactorAuthentication($request)) { + $request->session()->put('two_factor_confirming_at', $currentTime); + } + + // If the profile is reloaded and is not confirmed but was previously in confirming state, disable... + if ($this->neverFinishedConfirmingTwoFactorAuthentication($request, $currentTime)) { + app(DisableTwoFactorAuthentication::class)(Auth::user()); + + $request->session()->put('two_factor_empty_at', $currentTime); + $request->session()->remove('two_factor_confirming_at'); + } + } + + /** + * Determine if two factor authenticatoin is totally disabled. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function twoFactorAuthenticationDisabled(Request $request) + { + return is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at); + } + + /** + * Determine if two factor authentication is just now being confirmed within the last request cycle. + * + * @param \Illuminate\Http\Request $request + * @return bool + */ + protected function hasJustBegunConfirmingTwoFactorAuthentication(Request $request) + { + return ! is_null($request->user()->two_factor_secret) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->has('two_factor_empty_at') && + is_null($request->session()->get('two_factor_confirming_at')); + } + + /** + * Determine if two factor authentication was never totally confirmed once confirmation started. + * + * @param \Illuminate\Http\Request $request + * @param int $currentTime + * @return bool + */ + protected function neverFinishedConfirmingTwoFactorAuthentication(Request $request, $currentTime) + { + return ! array_key_exists('code', $request->session()->getOldInput()) && + is_null($request->user()->two_factor_confirmed_at) && + $request->session()->get('two_factor_confirming_at', 0) != $currentTime; + } +} diff --git a/src/Http/Controllers/CurrentTeamController.php b/src/Http/Controllers/CurrentTeamController.php new file mode 100644 index 0000000..f2bf7a9 --- /dev/null +++ b/src/Http/Controllers/CurrentTeamController.php @@ -0,0 +1,27 @@ +findOrFail($request->team_id); + + if (! $request->user()->switchTeam($team)) { + abort(403); + } + + return redirect(config('fortify.home'), 303); + } +} diff --git a/src/Http/Controllers/DashboardController.php b/src/Http/Controllers/DashboardController.php index 584f7c3..4f08e5d 100644 --- a/src/Http/Controllers/DashboardController.php +++ b/src/Http/Controllers/DashboardController.php @@ -23,76 +23,76 @@ public function index(): View return view('tomato-admin::pages.dashboard'); } - /** - * @return View - */ - public function profile(): View - { - return view('tomato-admin::profile.edit', [ - "user" => auth()->user() - ]); - } - - - /** - * @param ProfileUpdateRequest $request - * @return RedirectResponse - */ - public function update(ProfileUpdateRequest $request): \Illuminate\Http\RedirectResponse - { - $request->user()->fill($request->validated()); - - if ($request->user()->isDirty('email')) { - $request->user()->email_verified_at = null; - } - - $request->user()->save(); - - return Redirect::route('admin.profile.edit')->with('status', 'profile-updated'); - } - - - /** - * @param Request $request - * @return RedirectResponse - */ - public function password(Request $request): RedirectResponse - { - $validated = $request->validateWithBag('updatePassword', [ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], - ]); - - $request->user()->update([ - 'password' => Hash::make($validated['password']), - ]); - - return back()->with('status', 'password-updated'); - } - - - /** - * @param Request $request - * @return RedirectResponse - */ - public function destroy(Request $request): RedirectResponse - { - $request->validateWithBag('userDeletion', [ - 'password' => ['required', 'current-password'], - ]); - - $user = $request->user(); - - Auth::logout(); - - $user->delete(); - - $request->session()->invalidate(); - $request->session()->regenerateToken(); - - return Redirect::to('/'); - } - +// /** +// * @return View +// */ +// public function profile(): View +// { +// return view('tomato-admin::profile.edit', [ +// "user" => auth()->user() +// ]); +// } +// +// +// /** +// * @param ProfileUpdateRequest $request +// * @return RedirectResponse +// */ +// public function update(ProfileUpdateRequest $request): \Illuminate\Http\RedirectResponse +// { +// $request->user()->fill($request->validated()); +// +// if ($request->user()->isDirty('email')) { +// $request->user()->email_verified_at = null; +// } +// +// $request->user()->save(); +// +// return Redirect::route('admin.profile.edit')->with('status', 'profile-updated'); +// } +// +// +// /** +// * @param Request $request +// * @return RedirectResponse +// */ +// public function password(Request $request): RedirectResponse +// { +// $validated = $request->validateWithBag('updatePassword', [ +// 'current_password' => ['required', 'current_password'], +// 'password' => ['required', Password::defaults(), 'confirmed'], +// ]); +// +// $request->user()->update([ +// 'password' => Hash::make($validated['password']), +// ]); +// +// return back()->with('status', 'password-updated'); +// } +// +// +// /** +// * @param Request $request +// * @return RedirectResponse +// */ +// public function destroy(Request $request): RedirectResponse +// { +// $request->validateWithBag('userDeletion', [ +// 'password' => ['required', 'current-password'], +// ]); +// +// $user = $request->user(); +// +// Auth::logout(); +// +// $user->delete(); +// +// $request->session()->invalidate(); +// $request->session()->regenerateToken(); +// +// return Redirect::to('/'); +// } +// public function switchLang(Request $request){ if(!Cookie::has('lang')){ Cookie::queue('lang', json_encode(["id" => "en", "name" => "English"])); @@ -111,5 +111,12 @@ public function switchLang(Request $request){ } return redirect()->back(); } +// +// public function teams() +// { +// return view('tomato-admin::profile.teams', [ +// "user" => auth()->user() +// ]); +// } } diff --git a/src/Http/Controllers/OtherBrowserSessionsController.php b/src/Http/Controllers/OtherBrowserSessionsController.php new file mode 100644 index 0000000..614cef5 --- /dev/null +++ b/src/Http/Controllers/OtherBrowserSessionsController.php @@ -0,0 +1,57 @@ +user(), $request->password + ); + + if (! $confirmed) { + throw ValidationException::withMessages([ + 'password' => __('The password is incorrect.'), + ]); + } + + $guard->logoutOtherDevices($request->password); + + $this->deleteOtherSessionRecords($request); + + return back(303); + } + + /** + * Delete the other browser session records from storage. + * + * @param \Illuminate\Http\Request $request + * @return void + */ + protected function deleteOtherSessionRecords(Request $request) + { + if (config('session.driver') !== 'database') { + return; + } + + DB::connection(config('session.connection'))->table(config('session.table', 'sessions')) + ->where('user_id', $request->user()->getAuthIdentifier()) + ->where('id', '!=', $request->session()->getId()) + ->delete(); + } +} diff --git a/src/Http/Controllers/PrivacyPolicyController.php b/src/Http/Controllers/PrivacyPolicyController.php new file mode 100644 index 0000000..196d502 --- /dev/null +++ b/src/Http/Controllers/PrivacyPolicyController.php @@ -0,0 +1,26 @@ + Str::markdown(file_get_contents($policyFile)), + ]); + } +} diff --git a/src/Http/Controllers/ProfilePhotoController.php b/src/Http/Controllers/ProfilePhotoController.php new file mode 100644 index 0000000..54dbd10 --- /dev/null +++ b/src/Http/Controllers/ProfilePhotoController.php @@ -0,0 +1,22 @@ +user()->deleteProfilePhoto(); + + return back(303)->with('status', 'profile-photo-deleted'); + } +} diff --git a/src/Http/Controllers/Responses/FailedPasswordConfirmationResponse.php b/src/Http/Controllers/Responses/FailedPasswordConfirmationResponse.php new file mode 100644 index 0000000..aeae47b --- /dev/null +++ b/src/Http/Controllers/Responses/FailedPasswordConfirmationResponse.php @@ -0,0 +1,24 @@ + [$message], + ]); + } +} diff --git a/src/Http/Controllers/Responses/LoginResponse.php b/src/Http/Controllers/Responses/LoginResponse.php new file mode 100644 index 0000000..ed51402 --- /dev/null +++ b/src/Http/Controllers/Responses/LoginResponse.php @@ -0,0 +1,20 @@ +intended(Fortify::redirects('login')); + } +} diff --git a/src/Http/Controllers/Responses/LogoutResponse.php b/src/Http/Controllers/Responses/LogoutResponse.php new file mode 100644 index 0000000..73da040 --- /dev/null +++ b/src/Http/Controllers/Responses/LogoutResponse.php @@ -0,0 +1,20 @@ +intended(Fortify::redirects('password-confirmation')); + } +} diff --git a/src/Http/Controllers/Responses/PasswordResetResponse.php b/src/Http/Controllers/Responses/PasswordResetResponse.php new file mode 100644 index 0000000..9abb2c7 --- /dev/null +++ b/src/Http/Controllers/Responses/PasswordResetResponse.php @@ -0,0 +1,38 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return redirect(Fortify::redirects('password-reset', config('fortify.views', true) ? route('login') : null))->with('status', trans($this->status)); + } +} diff --git a/src/Http/Controllers/Responses/PasswordUpdateResponse.php b/src/Http/Controllers/Responses/PasswordUpdateResponse.php new file mode 100644 index 0000000..87f8b03 --- /dev/null +++ b/src/Http/Controllers/Responses/PasswordUpdateResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::PASSWORD_UPDATED); + } +} diff --git a/src/Http/Controllers/Responses/ProfileInformationUpdatedResponse.php b/src/Http/Controllers/Responses/ProfileInformationUpdatedResponse.php new file mode 100644 index 0000000..4c91433 --- /dev/null +++ b/src/Http/Controllers/Responses/ProfileInformationUpdatedResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::PROFILE_INFORMATION_UPDATED); + } +} diff --git a/src/Http/Controllers/Responses/RecoveryCodesGeneratedResponse.php b/src/Http/Controllers/Responses/RecoveryCodesGeneratedResponse.php new file mode 100644 index 0000000..688218d --- /dev/null +++ b/src/Http/Controllers/Responses/RecoveryCodesGeneratedResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::RECOVERY_CODES_GENERATED); + } +} diff --git a/src/Http/Controllers/Responses/RegisterResponse.php b/src/Http/Controllers/Responses/RegisterResponse.php new file mode 100644 index 0000000..be7e648 --- /dev/null +++ b/src/Http/Controllers/Responses/RegisterResponse.php @@ -0,0 +1,20 @@ +intended(Fortify::redirects('register')); + } +} diff --git a/src/Http/Controllers/Responses/SuccessfulPasswordResetLinkRequestResponse.php b/src/Http/Controllers/Responses/SuccessfulPasswordResetLinkRequestResponse.php new file mode 100644 index 0000000..2f5765f --- /dev/null +++ b/src/Http/Controllers/Responses/SuccessfulPasswordResetLinkRequestResponse.php @@ -0,0 +1,37 @@ +status = $status; + } + + /** + * Create an HTTP response that represents the object. + * + * @param \Illuminate\Http\Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function toResponse($request) + { + return back()->with('status', trans($this->status)); + } +} diff --git a/src/Http/Controllers/Responses/TwoFactorConfirmedResponse.php b/src/Http/Controllers/Responses/TwoFactorConfirmedResponse.php new file mode 100644 index 0000000..260d3e8 --- /dev/null +++ b/src/Http/Controllers/Responses/TwoFactorConfirmedResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::TWO_FACTOR_AUTHENTICATION_CONFIRMED); + } +} diff --git a/src/Http/Controllers/Responses/TwoFactorDisabledResponse.php b/src/Http/Controllers/Responses/TwoFactorDisabledResponse.php new file mode 100644 index 0000000..6e76470 --- /dev/null +++ b/src/Http/Controllers/Responses/TwoFactorDisabledResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::TWO_FACTOR_AUTHENTICATION_DISABLED); + } +} diff --git a/src/Http/Controllers/Responses/TwoFactorEnabledResponse.php b/src/Http/Controllers/Responses/TwoFactorEnabledResponse.php new file mode 100644 index 0000000..f1569e3 --- /dev/null +++ b/src/Http/Controllers/Responses/TwoFactorEnabledResponse.php @@ -0,0 +1,20 @@ +with('status', Fortify::TWO_FACTOR_AUTHENTICATION_ENABLED); + } +} diff --git a/src/Http/Controllers/Responses/TwoFactorLoginResponse.php b/src/Http/Controllers/Responses/TwoFactorLoginResponse.php new file mode 100644 index 0000000..ca037dd --- /dev/null +++ b/src/Http/Controllers/Responses/TwoFactorLoginResponse.php @@ -0,0 +1,20 @@ +intended(Fortify::redirects('login')); + } +} diff --git a/src/Http/Controllers/Responses/VerifyEmailResponse.php b/src/Http/Controllers/Responses/VerifyEmailResponse.php new file mode 100644 index 0000000..ff422ca --- /dev/null +++ b/src/Http/Controllers/Responses/VerifyEmailResponse.php @@ -0,0 +1,20 @@ +intended(Fortify::redirects('email-verification').'?verified=1'); + } +} diff --git a/src/Http/Controllers/TeamController.php b/src/Http/Controllers/TeamController.php new file mode 100644 index 0000000..d8ab6e2 --- /dev/null +++ b/src/Http/Controllers/TeamController.php @@ -0,0 +1,109 @@ +findOrFail($teamId); + + Gate::authorize('view', $team); + + return view('tomato-admin::teams.show', [ + 'team' => $team->load('owner', 'users', 'teamInvitations'), + 'availableRoles' => array_values(Jetstream::$roles), + 'availablePermissions' => Jetstream::$permissions, + 'defaultPermissions' => Jetstream::$defaultPermissions, + 'permissions' => [ + 'canAddTeamMembers' => Gate::check('addTeamMember', $team), + 'canDeleteTeam' => Gate::check('delete', $team), + 'canRemoveTeamMembers' => Gate::check('removeTeamMember', $team), + 'canUpdateTeam' => Gate::check('update', $team), + ], + ]); + } + + /** + * Show the team creation screen. + * + * @param \Illuminate\Http\Request $request + * @return \Splade\Response + */ + public function create(Request $request) + { + Gate::authorize('create', Jetstream::newTeamModel()); + + return view('tomato-admin::teams.create'); + } + + /** + * Create a new team. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request) + { + $creator = app(CreatesTeams::class); + + $creator->create($request->user(), $request->all()); + + return $this->redirectPath($creator); + } + + /** + * Update the given team's name. + * + * @param \Illuminate\Http\Request $request + * @param int $teamId + * @return \Illuminate\Http\RedirectResponse + */ + public function update(Request $request, $teamId) + { + $team = Jetstream::newTeamModel()->findOrFail($teamId); + + app(UpdatesTeamNames::class)->update($request->user(), $team, $request->all()); + + return back(303); + } + + /** + * Delete the given team. + * + * @param \Illuminate\Http\Request $request + * @param int $teamId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, $teamId) + { + $team = Jetstream::newTeamModel()->findOrFail($teamId); + + app(ValidateTeamDeletion::class)->validate($request->user(), $team); + + $deleter = app(DeletesTeams::class); + + $deleter->delete($team); + + return $this->redirectPath($deleter); + } +} diff --git a/src/Http/Controllers/TeamInvitationController.php b/src/Http/Controllers/TeamInvitationController.php new file mode 100644 index 0000000..ba35640 --- /dev/null +++ b/src/Http/Controllers/TeamInvitationController.php @@ -0,0 +1,62 @@ +firstOrFail(); + + app(AddsTeamMembers::class)->add( + $invitation->team->owner, + $invitation->team, + $invitation->email, + $invitation->role + ); + + $invitation->delete(); + + return redirect(config('fortify.home'))->banner( + __('Great! You have accepted the invitation to join the :team team.', ['team' => $invitation->team->name]), + ); + } + + /** + * Cancel the given team invitation. + * + * @param \Illuminate\Http\Request $request + * @param int $invitationId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, $invitationId) + { + $model = Jetstream::teamInvitationModel(); + + $invitation = $model::whereKey($invitationId)->firstOrFail(); + + if (! Gate::forUser($request->user())->check('removeTeamMember', $invitation->team)) { + throw new AuthorizationException; + } + + $invitation->delete(); + + return back(303); + } +} diff --git a/src/Http/Controllers/TeamMemberController.php b/src/Http/Controllers/TeamMemberController.php new file mode 100644 index 0000000..b7fe24f --- /dev/null +++ b/src/Http/Controllers/TeamMemberController.php @@ -0,0 +1,110 @@ +findOrFail($teamId); + + if (Features::sendsTeamInvitations()) { + app(InvitesTeamMembers::class)->invite( + $request->user(), + $team, + $request->email ?: '', + $request->role + ); + } else { + app(AddsTeamMembers::class)->add( + $request->user(), + $team, + $request->email ?: '', + $request->role + ); + } + + return back(303); + } + + /** + * Update the given team member's role. + * + * @param int $teamId + * @param int $userId + * @return \Illuminate\Http\RedirectResponse + */ + public function edit($teamId, $userId) + { + $team = Jetstream::newTeamModel()->findOrFail($teamId); + + $team->users()->findOrFail($userId); + + return view('tomato-admin::teams.team-member-role-form', [ + 'team' => $team, + 'user' => $team->users()->findOrFail($userId), + 'availableRoles' => array_values(Jetstream::$roles), + ]); + } + + /** + * Update the given team member's role. + * + * @param \Illuminate\Http\Request $request + * @param int $teamId + * @param int $userId + * @return \Illuminate\Http\RedirectResponse + */ + public function update(Request $request, $teamId, $userId) + { + app(UpdateTeamMemberRole::class)->update( + $request->user(), + Jetstream::newTeamModel()->findOrFail($teamId), + $userId, + $request->role + ); + + return back(303); + } + + /** + * Remove the given user from the given team. + * + * @param \Illuminate\Http\Request $request + * @param int $teamId + * @param int $userId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, $teamId, $userId) + { + $team = Jetstream::newTeamModel()->findOrFail($teamId); + + app(RemovesTeamMembers::class)->remove( + $request->user(), + $team, + $user = Jetstream::findUserByIdOrFail($userId) + ); + + if ($request->user()->id === $user->id) { + return redirect(config('fortify.home')); + } + + return back(303); + } +} diff --git a/src/Http/Controllers/TermsOfServiceController.php b/src/Http/Controllers/TermsOfServiceController.php new file mode 100644 index 0000000..b68edaa --- /dev/null +++ b/src/Http/Controllers/TermsOfServiceController.php @@ -0,0 +1,26 @@ + Str::markdown(file_get_contents($termsFile)), + ]); + } +} diff --git a/src/Http/Controllers/UserProfileController.php b/src/Http/Controllers/UserProfileController.php new file mode 100644 index 0000000..958461d --- /dev/null +++ b/src/Http/Controllers/UserProfileController.php @@ -0,0 +1,146 @@ +validateTwoFactorAuthenticationState($request); + + return view('tomato-admin::profile.edit', [ + 'confirmsTwoFactorAuthentication' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + 'sessions' => $this->sessions($request)->all(), + 'user' => auth('web')->user() + ]); + } + + /** + * Get the current sessions. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Support\Collection + */ + public function sessions(Request $request) + { + if (config('session.driver') !== 'database') { + return collect(); + } + + return collect( + DB::connection(config('session.connection'))->table(config('session.table', 'sessions')) + ->where('user_id', $request->user()->getAuthIdentifier()) + ->orderBy('last_activity', 'desc') + ->get() + )->map(function ($session) use ($request) { + $agent = $this->createAgent($session); + + return (object) [ + 'agent' => [ + 'is_desktop' => $agent->isDesktop(), + 'platform' => $agent->platform(), + 'browser' => $agent->browser(), + ], + 'ip_address' => $session->ip_address, + 'is_current_device' => $session->id === $request->session()->getId(), + 'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(), + ]; + }); + } + + /** + * Create a new agent instance from the given session. + * + * @param mixed $session + * @return \Laravel\Jetstream\Agent + */ + protected function createAgent($session) + { + return tap(new \Laravel\Jetstream\Agent, function ($agent) use ($session) { + $agent->setUserAgent($session->user_agent); + }); + } + + /** + * @param ProfileUpdateRequest $request + * @return RedirectResponse + */ + public function update(ProfileUpdateRequest $request): \Illuminate\Http\RedirectResponse + { + $request->user()->fill($request->validated()); + + if ($request->user()->isDirty('email')) { + $request->user()->email_verified_at = null; + } + + $request->user()->save(); + + return Redirect::route('admin.profile.edit')->with('status', 'profile-updated'); + } + + + /** + * @param Request $request + * @return RedirectResponse + */ + public function password(Request $request): RedirectResponse + { + $validated = $request->validateWithBag('updatePassword', [ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]); + + $request->user()->update([ + 'password' => Hash::make($validated['password']), + ]); + + return back()->with('status', 'password-updated'); + } + + + /** + * @param Request $request + * @return RedirectResponse + */ + public function destroy(Request $request): RedirectResponse + { + $request->validateWithBag('userDeletion', [ + 'password' => ['required', 'current-password'], + ]); + + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return Redirect::to('/'); + } + + + +} diff --git a/src/Models/Membership.php b/src/Models/Membership.php new file mode 100644 index 0000000..ffe00ba --- /dev/null +++ b/src/Models/Membership.php @@ -0,0 +1,15 @@ + $servers + */ +class Team extends JetstreamTeam +{ + use HasFactory; + use HasUlids; + + protected $casts = [ + 'personal_team' => 'boolean', + 'requires_subscription' => 'boolean', + ]; + + protected $fillable = [ + 'name', + 'personal_team', + ]; + + protected $dispatchesEvents = [ + 'created' => TeamCreated::class, + 'updated' => TeamUpdated::class, + 'deleted' => TeamDeleted::class, + ]; + + public function servers(): HasMany + { + return $this->hasMany(Server::class)->orderBy( + (new Server)->qualifyColumn('name') + ); + } + + public function activityLogs() + { + return $this->hasMany(ActivityLog::class); + } + + /** + * Returns an instanceof TeamSubscriptionOptions to determine + * what this team can do and what it can't. + */ + public function subscriptionOptions(): TeamSubscriptionOptions + { + return app()->makeWith(TeamSubscriptionOptions::class, ['team' => $this]); + } + + /** + * Get the email address that should be associated with the Paddle customer. + */ + public function paddleEmail(): ?string + { + $owner = $this->owner; + + return $owner->email; + } +} diff --git a/src/Models/TeamInvitation.php b/src/Models/TeamInvitation.php new file mode 100644 index 0000000..0d4a960 --- /dev/null +++ b/src/Models/TeamInvitation.php @@ -0,0 +1,26 @@ +belongsTo(Jetstream::teamModel()); + } +} diff --git a/src/Policies/TeamPolicy.php b/src/Policies/TeamPolicy.php new file mode 100644 index 0000000..f4c38bd --- /dev/null +++ b/src/Policies/TeamPolicy.php @@ -0,0 +1,76 @@ +belongsToTeam($team); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Team $team): bool + { + return $user->ownsTeam($team); + } + + /** + * Determine whether the user can add team members. + */ + public function addTeamMember(User $user, Team $team): bool + { + return $user->ownsTeam($team); + } + + /** + * Determine whether the user can update team member permissions. + */ + public function updateTeamMember(User $user, Team $team): bool + { + return $user->ownsTeam($team); + } + + /** + * Determine whether the user can remove team members. + */ + public function removeTeamMember(User $user, Team $team): bool + { + return $user->ownsTeam($team); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Team $team): bool + { + return $user->ownsTeam($team); + } +} diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..370f344 --- /dev/null +++ b/src/Providers/AuthServiceProvider.php @@ -0,0 +1,29 @@ + + */ + protected $policies = [ + Team::class => TeamPolicy::class, + ]; + + /** + * Register any authentication / authorization services. + */ + public function boot(): void + { + $this->registerPolicies(); + + // + } +} diff --git a/src/Providers/FortifyServiceProvider.php b/src/Providers/FortifyServiceProvider.php new file mode 100644 index 0000000..932ba97 --- /dev/null +++ b/src/Providers/FortifyServiceProvider.php @@ -0,0 +1,65 @@ +email; + + return Limit::perMinute(5)->by($email.$request->ip()); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } +} diff --git a/src/Providers/JetstreamServiceProvider.php b/src/Providers/JetstreamServiceProvider.php new file mode 100644 index 0000000..ca2bf8a --- /dev/null +++ b/src/Providers/JetstreamServiceProvider.php @@ -0,0 +1,45 @@ +configurePermissions(); + + Jetstream::deleteUsersUsing(DeleteUser::class); + + } + + /** + * Configure the permissions that are available within the application. + */ + protected function configurePermissions(): void + { + Jetstream::defaultApiTokenPermissions(['read']); + + Jetstream::permissions([ + 'create', + 'read', + 'update', + 'delete', + ]); + } +} diff --git a/src/Providers/JetstreamWithTeamsServiceProvider.php b/src/Providers/JetstreamWithTeamsServiceProvider.php new file mode 100644 index 0000000..fb3ac35 --- /dev/null +++ b/src/Providers/JetstreamWithTeamsServiceProvider.php @@ -0,0 +1,61 @@ +configurePermissions(); + + Jetstream::createTeamsUsing(CreateTeam::class); + Jetstream::updateTeamNamesUsing(UpdateTeamName::class); + Jetstream::addTeamMembersUsing(AddTeamMember::class); + Jetstream::inviteTeamMembersUsing(InviteTeamMember::class); + Jetstream::removeTeamMembersUsing(RemoveTeamMember::class); + Jetstream::deleteTeamsUsing(DeleteTeam::class); + Jetstream::deleteUsersUsing(DeleteUser::class); + } + + /** + * Configure the roles and permissions that are available within the application. + */ + protected function configurePermissions(): void + { + Jetstream::defaultApiTokenPermissions(['read']); + + Jetstream::role('admin', 'Administrator', [ + 'create', + 'read', + 'update', + 'delete', + ])->description('Administrator users can perform any action.'); + + Jetstream::role('editor', 'Editor', [ + 'read', + 'create', + 'update', + ])->description('Editor users have the ability to read, create, and update.'); + } +} diff --git a/src/Services/TeamSubscriptionOptions.php b/src/Services/TeamSubscriptionOptions.php new file mode 100644 index 0000000..3876496 --- /dev/null +++ b/src/Services/TeamSubscriptionOptions.php @@ -0,0 +1,107 @@ +team->requires_subscription && config('tomato-admin.subscriptions_enabled'); + } + + private function onTrial(): bool + { + return $this->team->onTrial(); + } + + private function isSubscribed(): bool + { + return $this->team->subscribed(); + } + + public function onTrialOrIsSubscribed(): bool + { + return $this->onTrial() || $this->isSubscribed(); + } + + public function planOptions(): array + { + if ($this->onTrial()) { + return Arr::first(config('spark.billables.team.plans'))['options']; + } + + if (! $this->isSubscribed()) { + return [ + 'max_servers' => 0, + 'max_sites_per_server' => 0, + 'max_deployments_per_site' => 5, + 'max_team_members' => 1, + ]; + } + + return data_get($this->team->sparkPlan(), 'options'); + } + + private function limitNotReached(string $optionKey, int $currentValue): bool + { + if (! $this->mustVerifySubscription()) { + return true; + } + + $max = $this->planOptions()[$optionKey]; + + if ($max === false) { + return true; + } + + return $currentValue < $max; + } + + public function maxDeploymentsPerSite(): bool|int + { + if (! $this->mustVerifySubscription()) { + return false; + } + + return $this->planOptions()['max_deployments_per_site']; + } + + public function countServers(): int + { + return $this->team->servers()->count(); + } + + public function canCreateServer(): bool + { + return $this->limitNotReached('max_servers', $this->countServers()); + } + + public function countSitesOnServer(Server $server): int + { + return $server->sites()->count(); + } + + public function canCreateSiteOnServer(Server $server): bool + { + return $this->limitNotReached('max_sites_per_server', $this->countSitesOnServer($server)); + } + + public function countTeamMembers(): int + { + // Also count the owner as a team member. + return $this->team->users()->count() + 1; + } + + public function canAddTeamMember(): bool + { + return $this->limitNotReached('max_team_members', $this->countTeamMembers()); + } +} diff --git a/src/TomatoAdminServiceProvider.php b/src/TomatoAdminServiceProvider.php index 1a0a592..9d7ca9e 100644 --- a/src/TomatoAdminServiceProvider.php +++ b/src/TomatoAdminServiceProvider.php @@ -1,13 +1,24 @@ resource_path('views/vendor/tomato-admin'), ], 'tomato-admin-views'); + //Publish Migrations + $this->publishes([ + __DIR__.'/../database/migrations' => base_path('database/migrations'), + ], 'tomato-admin-migrations'); + + $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->commands([ TomatoAdminInstall::class, @@ -110,6 +129,18 @@ public function register(): void return new TomatoRequests(); }); + Config::set('fortify.middleware', ['splade','web']); + Config::set('fortify.home', '/admin'); + Config::set('fortify.prefix', 'admin'); + Config::set('jetstream.stack', 'inertia'); + Config::set('jetstream.middleware', ['web', 'splade']); + + $this->app->register(AuthServiceProvider::class); + $this->app->register(FortifyServiceProvider::class); + $this->app->register(JetstreamWithTeamsServiceProvider::class); + + $this->bootSplade(); + } /** @@ -161,8 +192,53 @@ public function boot(): void $this->loadViewComponentsAs('tomato', [ Search::class, - Items::class + Items::class, + ApplicationLogo::class, + AuthSessionStatus::class ]); + + Jetstream::useTeamModel(Team::class); + Jetstream::useTeamInvitationModel(TeamInvitation::class); + Jetstream::useMembershipModel(Membership::class); + + } + + + /** + * Boot any Splade related services. + * + * @return void + */ + protected function bootSplade() + { + $this->app->singleton(Contracts\FailedPasswordConfirmationResponse::class, Responses\FailedPasswordConfirmationResponse::class); + $this->app->singleton(Contracts\LoginResponse::class, Responses\LoginResponse::class); + $this->app->singleton(Contracts\LogoutResponse::class, Responses\LogoutResponse::class); + $this->app->singleton(Contracts\PasswordConfirmedResponse::class, Responses\PasswordConfirmedResponse::class); + $this->app->singleton(Contracts\PasswordResetResponse::class, Responses\PasswordResetResponse::class); + $this->app->singleton(Contracts\PasswordUpdateResponse::class, Responses\PasswordUpdateResponse::class); + $this->app->singleton(Contracts\ProfileInformationUpdatedResponse::class, Responses\ProfileInformationUpdatedResponse::class); + $this->app->singleton(Contracts\RecoveryCodesGeneratedResponse::class, Responses\RecoveryCodesGeneratedResponse::class); + $this->app->singleton(Contracts\RegisterResponse::class, Responses\RegisterResponse::class); + $this->app->singleton(Contracts\SuccessfulPasswordResetLinkRequestResponse::class, Responses\SuccessfulPasswordResetLinkRequestResponse::class); + $this->app->singleton(Contracts\TwoFactorConfirmedResponse::class, Responses\TwoFactorConfirmedResponse::class); + $this->app->singleton(Contracts\TwoFactorDisabledResponse::class, Responses\TwoFactorDisabledResponse::class); + $this->app->singleton(Contracts\TwoFactorEnabledResponse::class, Responses\TwoFactorEnabledResponse::class); + $this->app->singleton(Contracts\TwoFactorLoginResponse::class, Responses\TwoFactorLoginResponse::class); + $this->app->singleton(Contracts\VerifyEmailResponse::class, Responses\VerifyEmailResponse::class); + + SpladeMiddleware::afterOriginalResponse(function () { + if (! session('flash.banner')) { + return; + } + + Splade::share('jetstreamBanner', function () { + return [ + 'banner' => session('flash.banner'), + 'bannerStyle' => session('flash.bannerStyle'), + ]; + }); + }); } } diff --git a/src/Views/ApplicationLogo.php b/src/Views/ApplicationLogo.php new file mode 100644 index 0000000..e2b18ba --- /dev/null +++ b/src/Views/ApplicationLogo.php @@ -0,0 +1,19 @@ +