diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 63d24a219..2116c9922 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers; +use App\Http\Livewire\Team\Invitations; use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; +use App\Models\TeamInvitation; +use App\Models\User; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Routing\Controller as BaseController; @@ -53,14 +56,56 @@ public function emails() } public function team() { - ray(auth()->user()->isAdmin()); $invitations = []; if (auth()->user()->isAdmin()) { - $invitations = auth()->user()->currentTeam()->invitations; + $invitations = TeamInvitation::whereTeamId(auth()->user()->currentTeam()->id)->get(); } return view('team.show', [ - 'transactional_emails_active' => data_get(InstanceSettings::get(), 'extra_attributes.smtp_host') ? true : false, + 'transactional_emails_active' => is_transactional_emails_active(), 'invitations' => $invitations, ]); } + public function accept_invitation() + { + try { + $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail(); + $user = User::whereEmail($invitation->email)->firstOrFail(); + if (is_null(auth()->user())) { + return redirect()->route('login'); + } + if (auth()->user()->id !== $user->id) { + abort(401); + } + + $created_at = $invitation->created_at; + $diff = $created_at->diffInMinutes(now()); + if ($diff <= config('constants.invitation.link.expiration')) { + $user->teams()->attach($invitation->team->id, ['role' => $invitation->role]); + $invitation->delete(); + return redirect()->route('team.show'); + } else { + $invitation->delete(); + abort(401); + } + } catch (\Throwable $th) { + throw $th; + } + } + public function revoke_invitation() + { + try { + $invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail(); + $user = User::whereEmail($invitation->email)->firstOrFail(); + if (is_null(auth()->user())) { + return redirect()->route('login'); + } + if (auth()->user()->id !== $user->id) { + abort(401); + } + $invitation->delete(); + return redirect()->route('team.show'); + } catch (\Throwable $th) { + throw $th; + } + } } diff --git a/app/Http/Livewire/Settings/Email.php b/app/Http/Livewire/Settings/Email.php index 42a1c7000..311e24405 100644 --- a/app/Http/Livewire/Settings/Email.php +++ b/app/Http/Livewire/Settings/Email.php @@ -2,10 +2,8 @@ namespace App\Http\Livewire\Settings; -use App\Mail\TestTransactionalEmail; use App\Models\InstanceSettings; -use App\Notifications\TestTransactionEmail; -use Illuminate\Support\Facades\Mail; +use App\Notifications\TransactionalEmails\TestEmail; use Illuminate\Support\Facades\Notification; use Livewire\Component; @@ -20,46 +18,17 @@ class Email extends Component 'settings.extra_attributes.smtp_username' => 'nullable', 'settings.extra_attributes.smtp_password' => 'nullable', 'settings.extra_attributes.smtp_timeout' => 'nullable', - 'settings.extra_attributes.smtp_recipients' => 'required', 'settings.extra_attributes.smtp_test_recipients' => 'nullable', 'settings.extra_attributes.smtp_from_address' => 'required|email', 'settings.extra_attributes.smtp_from_name' => 'required', ]; public function test_email() { - Notification::send($this->settings, new TestTransactionEmail); + Notification::send($this->settings, new TestEmail); } - // public function test_email() - // { - // config()->set('mail.default', 'smtp'); - // config()->set('mail.mailers.smtp', [ - // "transport" => "smtp", - // "host" => $this->settings->smtp_host, - // "port" => $this->settings->smtp_port, - // "encryption" => $this->settings->smtp_encryption, - // "username" => $this->settings->smtp_username, - // "password" => $this->settings->smtp_password, - // ]); - - // $this->send_email(); - // } - // public function test_email_local() - // { - // config()->set('mail.default', 'smtp'); - // config()->set('mail.mailers.smtp', [ - // "transport" => "smtp", - // "host" => 'coolify-mail', - // "port" => 1025, - // ]); - // $this->send_email(); - // } - // private function send_email() - // { - // } public function submit() { $this->validate(); - $this->settings->extra_attributes->smtp_recipients = str_replace(' ', '', $this->settings->extra_attributes->smtp_recipients); $this->settings->extra_attributes->smtp_test_recipients = str_replace(' ', '', $this->settings->extra_attributes->smtp_test_recipients); $this->settings->save(); } diff --git a/app/Http/Livewire/Team/Invitations.php b/app/Http/Livewire/Team/Invitations.php new file mode 100644 index 000000000..e61aca0e7 --- /dev/null +++ b/app/Http/Livewire/Team/Invitations.php @@ -0,0 +1,21 @@ +invitations = TeamInvitation::whereTeamId(auth()->user()->currentTeam()->id)->get(); + } + public function deleteInvitation(int $invitation_id) + { + TeamInvitation::find($invitation_id)->delete(); + $this->refreshInvitations(); + } +} diff --git a/app/Http/Livewire/Team/InviteLink.php b/app/Http/Livewire/Team/InviteLink.php index 04159fc15..a557ea3d5 100644 --- a/app/Http/Livewire/Team/InviteLink.php +++ b/app/Http/Livewire/Team/InviteLink.php @@ -4,42 +4,64 @@ use App\Models\TeamInvitation; use App\Models\User; +use App\Notifications\TransactionalEmails\InvitationLinkEmail; use Livewire\Component; use Visus\Cuid2\Cuid2; class InviteLink extends Component { public string $email; + public string $role = 'member'; public function mount() { - $this->email = config('app.env') === 'local' ? 'test@example.com' : ''; + $this->email = config('app.env') === 'local' ? 'test3@example.com' : ''; } - public function inviteByLink() + public function viaEmail() + { + $this->generate_invite_link(isEmail: true); + } + private function generate_invite_link(bool $isEmail = false) { - $uuid = new Cuid2(32); - $link = url('/') . '/api/invitation/' . $uuid; try { - $user_exists = User::whereEmail($this->email)->exists(); - if (!$user_exists) { + $uuid = new Cuid2(32); + $link = url('/') . config('constants.invitation.link.base_url') . $uuid; + + $user = User::whereEmail($this->email); + + if (!$user->exists()) { return general_error_handler(that: $this, customErrorMessage: "$this->email must be registered first (or activate transactional emails to invite via email)."); } - $invitation = TeamInvitation::where('email', $this->email); + + $member_emails = session('currentTeam')->members()->get()->pluck('email'); + if ($member_emails->contains($this->email)) { + return general_error_handler(that: $this, customErrorMessage: "$this->email is already a member of " . session('currentTeam')->name . "."); + } + + $invitation = TeamInvitation::whereEmail($this->email); + if ($invitation->exists()) { $created_at = $invitation->first()->created_at; $diff = $created_at->diffInMinutes(now()); - if ($diff < 11) { - return general_error_handler(that: $this, customErrorMessage: "Invitation already sent and active for $this->email."); + if ($diff <= config('constants.invitation.link.expiration')) { + return general_error_handler(that: $this, customErrorMessage: "Invitation already sent to $this->email and waiting for action."); } else { $invitation->delete(); } } - $invitation = TeamInvitation::firstOrCreate([ + + TeamInvitation::firstOrCreate([ 'team_id' => session('currentTeam')->id, + 'uuid' => $uuid, 'email' => $this->email, - 'role' => 'readonly', + 'role' => $this->role, 'link' => $link, + 'via' => $isEmail ? 'email' : 'link', ]); - $this->emit('reloadWindow'); + if ($isEmail) { + $user->first()->notify(new InvitationLinkEmail()); + } + $this->emit('refreshInvitations'); + $this->emit('message', 'Invitation sent successfully.'); } catch (\Throwable $e) { $error_message = $e->getMessage(); if ($e->getCode() === '23505') { @@ -48,4 +70,8 @@ public function inviteByLink() return general_error_handler(err: $e, that: $this, customErrorMessage: $error_message); } } + public function inviteByLink() + { + $this->generate_invite_link(); + } } diff --git a/app/Http/Livewire/Team/Member.php b/app/Http/Livewire/Team/Member.php index 65cca3038..5f037e444 100644 --- a/app/Http/Livewire/Team/Member.php +++ b/app/Http/Livewire/Team/Member.php @@ -15,7 +15,7 @@ public function makeAdmin() } public function makeReadonly() { - $this->member->teams()->updateExistingPivot(session('currentTeam')->id, ['role' => 'readonly']); + $this->member->teams()->updateExistingPivot(session('currentTeam')->id, ['role' => 'member']); $this->emit('reloadWindow'); } public function remove() diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index 9a3ba1a75..cc66facd4 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -18,7 +18,7 @@ public function scopeWithExtraAttributes(): Builder { return $this->extra_attributes->modelScope(); } - public function routeNotificationForEmail(string $attribute = 'smtp_recipients') + public function routeNotificationForEmail(string $attribute = 'smtp_test_recipients') { $recipients = $this->extra_attributes->get($attribute, ''); if (is_null($recipients) || $recipients === '') { diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index 91ad9837a..c326d7d40 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -8,9 +8,11 @@ class TeamInvitation extends Model { protected $fillable = [ 'team_id', + 'uuid', 'email', 'role', 'link', + 'via', ]; public function team() { diff --git a/app/Models/User.php b/app/Models/User.php index 9f64bcd5c..a043ae89d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Notifications\Channels\SendsEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -10,7 +11,7 @@ use Visus\Cuid2\Cuid2; use Laravel\Fortify\TwoFactorAuthenticatable; -class User extends Authenticatable +class User extends Authenticatable implements SendsEmail { use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; protected $fillable = [ @@ -46,10 +47,13 @@ protected static function boot() $user->teams()->attach($new_team, ['role' => 'owner']); }); } + public function routeNotificationForEmail() + { + return $this->email; + } public function isAdmin() { if (auth()->user()->id === 0) { - ray('is root user'); return true; } $teams = $this->teams()->get(); @@ -59,7 +63,6 @@ public function isAdmin() ($is_part_of_root_team->pivot->role === 'admin' || $is_part_of_root_team->pivot->role === 'owner'); if ($is_part_of_root_team && $is_admin_of_root_team) { - ray('is admin of root team'); return true; } $role = $teams->where('id', session('currentTeam')->id)->first()->pivot->role; diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index c4e8eeccd..b2da2bb24 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -12,11 +12,10 @@ public function send(SendsEmail $notifiable, Notification $notification): void { $this->bootConfigs($notifiable); - if ($notification instanceof \App\Notifications\TestNotification) { + $is_test_notification = $notification instanceof \App\Notifications\TestNotification; + + if ($is_test_notification) { $bcc = $notifiable->routeNotificationForEmail('smtp_test_recipients'); - if (count($bcc) === 0) { - $bcc = $notifiable->routeNotificationForEmail(); - } } else { $bcc = $notifiable->routeNotificationForEmail(); } diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php new file mode 100644 index 000000000..1fb3f1971 --- /dev/null +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -0,0 +1,51 @@ +email; + if (!$email) { + return; + } + $settings = InstanceSettings::get(); + $this->bootConfigs($settings); + $mailMessage = $notification->toMail($notifiable); + + Mail::send( + [], + [], + fn (Message $message) => $message + ->from( + $settings->extra_attributes?->get('smtp_from_address'), + $settings->extra_attributes?->get('smtp_from_name') + ) + ->to($email) + ->subject($mailMessage->subject) + ->html((string)$mailMessage->render()) + ); + } + + private function bootConfigs(InstanceSettings $settings): void + { + config()->set('mail.default', 'smtp'); + config()->set('mail.mailers.smtp', [ + "transport" => "smtp", + "host" => $settings->extra_attributes?->get('smtp_host'), + "port" => $settings->extra_attributes?->get('smtp_port'), + "encryption" => $settings->extra_attributes?->get('smtp_encryption'), + "username" => $settings->extra_attributes?->get('smtp_username'), + "password" => $settings->extra_attributes?->get('smtp_password'), + "timeout" => $settings->extra_attributes?->get('smtp_timeout'), + "local_domain" => null, + ]); + } +} diff --git a/app/Notifications/TestNotification.php b/app/Notifications/TestNotification.php index d388635e7..bc207be88 100644 --- a/app/Notifications/TestNotification.php +++ b/app/Notifications/TestNotification.php @@ -12,20 +12,6 @@ class TestNotification extends Notification implements ShouldQueue { use Queueable; - - /** - * Create a new notification instance. - */ - public function __construct() - { - // - } - - /** - * Get the notification's delivery channels. - * - * @return array - */ public function via(object $notifiable): array { $channels = []; @@ -33,11 +19,7 @@ public function via(object $notifiable): array $notifiable->extra_attributes?->get('discord_active') && $channels[] = DiscordChannel::class; return $channels; } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage + public function toMail(): MailMessage { return (new MailMessage) ->subject('Coolify Test Notification') @@ -45,20 +27,8 @@ public function toMail(object $notifiable): MailMessage ->line('You have successfully received a test Email notification from Coolify. 🥳'); } - public function toDiscord(object $notifiable): string + public function toDiscord(): string { return 'You have successfully received a test Discord notification from Coolify. 🥳 [Go to your dashboard](' . url('/') . ')'; } - - /** - * Get the array representation of the notification. - * - * @return array - */ - public function toArray(object $notifiable): array - { - return [ - // - ]; - } } diff --git a/app/Notifications/TestTransactionEmail.php b/app/Notifications/TestTransactionEmail.php deleted file mode 100644 index 685cbea7d..000000000 --- a/app/Notifications/TestTransactionEmail.php +++ /dev/null @@ -1,52 +0,0 @@ - - */ - public function via(object $notifiable): array - { - $channels = []; - $notifiable->extra_attributes?->get('smtp_host') && $channels[] = EmailChannel::class; - return $channels; - } - - /** - * Get the mail representation of the notification. - */ - public function toMail(object $notifiable): MailMessage - { - $mail = new MailMessage(); - $mail->subject('Coolify Test Notification'); - $mail->view('emails.test-email'); - return $mail; - } - - public function toArray(object $notifiable): array - { - return [ - // - ]; - } -} diff --git a/app/Notifications/TransactionalEmails/InvitationLinkEmail.php b/app/Notifications/TransactionalEmails/InvitationLinkEmail.php new file mode 100644 index 000000000..fe53c6fce --- /dev/null +++ b/app/Notifications/TransactionalEmails/InvitationLinkEmail.php @@ -0,0 +1,36 @@ +email)->first(); + $invitation_team = Team::find($invitation->team->id); + + $mail = new MailMessage(); + $mail->subject('Invitation for ' . $invitation_team->name); + $mail->view('emails.invitation-link', [ + 'team' => $invitation_team->name, + 'email' => $user->email, + 'invitation_link' => $invitation->link, + ]); + return $mail; + } +} diff --git a/app/Notifications/TransactionalEmails/TestEmail.php b/app/Notifications/TransactionalEmails/TestEmail.php new file mode 100644 index 000000000..19e45dfc2 --- /dev/null +++ b/app/Notifications/TransactionalEmails/TestEmail.php @@ -0,0 +1,26 @@ +subject('Coolify Test Notification'); + $mail->view('emails.test'); + return $mail; + } +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 8a7a99af8..d5259ae94 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1,5 +1,6 @@ [ + 'link' => [ + 'base_url' => '/invitations/', + 'expiration' => 10, + ], + ], +]; diff --git a/database/migrations/2023_03_20_112812_create_team_user_table.php b/database/migrations/2023_03_20_112812_create_team_user_table.php index e794dc645..9eeeb4b31 100644 --- a/database/migrations/2023_03_20_112812_create_team_user_table.php +++ b/database/migrations/2023_03_20_112812_create_team_user_table.php @@ -15,7 +15,7 @@ public function up(): void $table->id(); $table->foreignId('team_id'); $table->foreignId('user_id'); - $table->string('role')->default('readonly'); + $table->string('role')->default('member'); $table->timestamps(); $table->unique(['team_id', 'user_id']); diff --git a/database/migrations/2023_03_20_112813_create_team_invitations_table.php b/database/migrations/2023_03_20_112813_create_team_invitations_table.php index 55be41302..9bce2be92 100644 --- a/database/migrations/2023_03_20_112813_create_team_invitations_table.php +++ b/database/migrations/2023_03_20_112813_create_team_invitations_table.php @@ -13,10 +13,12 @@ public function up(): void { Schema::create('team_invitations', function (Blueprint $table) { $table->id(); + $table->string('uuid')->unique(); $table->foreignId('team_id')->constrained()->cascadeOnDelete(); $table->string('email'); - $table->string('role')->default('readonly'); - $table->string('link')->nullable(); + $table->string('role')->default('member'); + $table->string('link'); + $table->string('via')->default('link'); $table->timestamps(); $table->unique(['team_id', 'email']); diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php index d9356d011..2e7c166d6 100644 --- a/database/seeders/InstanceSettingsSeeder.php +++ b/database/seeders/InstanceSettingsSeeder.php @@ -16,7 +16,7 @@ public function run(): void 'id' => 0, 'is_registration_enabled' => true, 'extra_attributes' => [ - 'smtp_recipients' => 'test@example.com,test2@example.com', + 'smtp_test_recipients' => 'test@example.com,test2@example.com', 'smtp_host' => 'coolify-mail', 'smtp_port' => 1025, 'smtp_from_address' => 'hi@localhost.com', diff --git a/resources/views/components/applications/navbar.blade.php b/resources/views/components/applications/navbar.blade.php index 466919615..6a5fa2665 100644 --- a/resources/views/components/applications/navbar.blade.php +++ b/resources/views/components/applications/navbar.blade.php @@ -1,4 +1,4 @@ -