diff --git a/_ide_helper_models.php b/_ide_helper_models.php index e82f42b8..7b320393 100644 --- a/_ide_helper_models.php +++ b/_ide_helper_models.php @@ -13,11 +13,11 @@ namespace App\Models\Billing{ /** - * App\Models\Billing\Subscription + * * * @property int $id * @property int $user_id - * @property string $name + * @property string $type * @property string $stripe_id * @property string $stripe_status * @property string|null $stripe_price @@ -32,14 +32,12 @@ namespace App\Models\Billing{ * @property-read \App\Models\User|null $user * @method static \Illuminate\Database\Eloquent\Builder|Subscription active() * @method static \Illuminate\Database\Eloquent\Builder|Subscription canceled() - * @method static \Illuminate\Database\Eloquent\Builder|Subscription cancelled() * @method static \Illuminate\Database\Eloquent\Builder|Subscription ended() * @method static \Illuminate\Database\Eloquent\Builder|Subscription expiredTrial() * @method static \Illuminate\Database\Eloquent\Builder|Subscription incomplete() * @method static \Illuminate\Database\Eloquent\Builder|Subscription newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Subscription newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Subscription notCanceled() - * @method static \Illuminate\Database\Eloquent\Builder|Subscription notCancelled() * @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnGracePeriod() * @method static \Illuminate\Database\Eloquent\Builder|Subscription notOnTrial() * @method static \Illuminate\Database\Eloquent\Builder|Subscription onGracePeriod() @@ -50,12 +48,12 @@ namespace App\Models\Billing{ * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereEndsAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereId($value) - * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereQuantity($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeId($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripePrice($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereStripeStatus($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereTrialEndsAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereType($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Subscription whereUserId($value) */ @@ -64,12 +62,12 @@ namespace App\Models\Billing{ namespace App\Models\Forms\AI{ /** - * App\Models\Forms\AI\AiFormCompletion + * * * @property int $id * @property string $form_prompt * @property string $status - * @property mixed|null $result + * @property string|null $result * @property string $ip * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at @@ -89,7 +87,7 @@ namespace App\Models\Forms\AI{ namespace App\Models\Forms{ /** - * App\Models\Forms\Form + * * * @property int $id * @property int $workspace_id @@ -98,7 +96,6 @@ namespace App\Models\Forms{ * @property array $properties * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at - * @property bool $notifies * @property string|null $description * @property string $submit_button_text * @property bool $re_fillable @@ -109,43 +106,35 @@ namespace App\Models\Forms{ * @property bool $hide_title * @property string $submitted_text * @property string $dark_mode - * @property string|null $webhook_url - * @property bool $send_submission_confirmation * @property string|null $logo_picture * @property string|null $cover_picture * @property string|null $redirect_url * @property string|null $custom_code - * @property string|null $notification_emails * @property string $theme * @property array|null $database_fields_update * @property string $width * @property bool $transparent_background * @property \Illuminate\Support\Carbon|null $closes_at * @property string|null $closed_text - * @property string $notification_subject - * @property string $notification_body - * @property bool $notifications_include_submission * @property bool $use_captcha * @property bool $can_be_indexed * @property string|null $password - * @property string $notification_sender - * @property array|null $tags + * @property array $tags * @property \Illuminate\Support\Carbon|null $deleted_at * @property int $creator_id - * @property-read array|null $removed_properties + * @property-read array $removed_properties * @property int|null $max_submissions_count * @property string|null $max_submissions_reached_text - * @property string|null $slack_webhook_url * @property string $visibility * @property bool $editable_submissions - * @property string|null $discord_webhook_url * @property string $editable_submissions_button_text * @property bool $confetti_on_submission * @property object $seo_meta - * @property object|null $notification_settings * @property bool $auto_save * @property string|null $custom_domain * @property bool $show_progress_bar + * @property string $size + * @property string $border_radius * @property-read \App\Models\User $creator * @property-read mixed $edit_url * @property-read mixed $form_pending_submission_key @@ -154,12 +143,11 @@ namespace App\Models\Forms{ * @property-read mixed $is_pro * @property-read mixed $max_file_size * @property-read mixed $max_number_of_submissions_reached - * @property-read mixed $notifies_discord - * @property-read mixed $notifies_slack - * @property-read mixed $notifies_webhook * @property-read mixed $share_url * @property-read int|null $submissions_count * @property-read int|null $views_count + * @property-read \Illuminate\Database\Eloquent\Collection $integrations + * @property-read int|null $integrations_count * @property-read \Illuminate\Database\Eloquent\Collection $statistics * @property-read int|null $statistics_count * @property-read \Illuminate\Database\Eloquent\Collection $submissions @@ -173,6 +161,7 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form onlyTrashed() * @method static \Illuminate\Database\Eloquent\Builder|Form query() * @method static \Illuminate\Database\Eloquent\Builder|Form whereAutoSave($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereBorderRadius($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereCanBeIndexed($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereClosedText($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereClosesAt($value) @@ -187,7 +176,6 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form whereDatabaseFieldsUpdate($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereDeletedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereDescription($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereDiscordWebhookUrl($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissions($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereEditableSubmissionsButtonText($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereHideTitle($value) @@ -196,23 +184,15 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsCount($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereMaxSubmissionsReachedText($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereNoBranding($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationBody($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationEmails($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSender($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSettings($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationSubject($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotificationsIncludeSubmission($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereNotifies($value) * @method static \Illuminate\Database\Eloquent\Builder|Form wherePassword($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereProperties($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillButtonText($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereReFillable($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereRedirectUrl($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereRemovedProperties($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereSendSubmissionConfirmation($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSeoMeta($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereShowProgressBar($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlackWebhookUrl($value) + * @method static \Illuminate\Database\Eloquent\Builder|Form whereSize($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSlug($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmitButtonText($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereSubmittedText($value) @@ -224,7 +204,6 @@ namespace App\Models\Forms{ * @method static \Illuminate\Database\Eloquent\Builder|Form whereUppercaseLabels($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereUseCaptcha($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereVisibility($value) - * @method static \Illuminate\Database\Eloquent\Builder|Form whereWebhookUrl($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereWidth($value) * @method static \Illuminate\Database\Eloquent\Builder|Form whereWorkspaceId($value) * @method static \Illuminate\Database\Eloquent\Builder|Form withTrashed() @@ -235,7 +214,7 @@ namespace App\Models\Forms{ namespace App\Models\Forms{ /** - * App\Models\Forms\FormStatistic + * * * @property int $id * @property int $form_id @@ -255,7 +234,7 @@ namespace App\Models\Forms{ namespace App\Models\Forms{ /** - * App\Models\Forms\FormSubmission + * * * @property int $id * @property int $form_id @@ -277,7 +256,7 @@ namespace App\Models\Forms{ namespace App\Models\Forms{ /** - * App\Models\Forms\FormView + * * * @property int $id * @property int $form_id @@ -297,7 +276,7 @@ namespace App\Models\Forms{ namespace App\Models\Integration{ /** - * App\Models\Integration\FormIntegration + * * * @property int $id * @property int $form_id @@ -311,6 +290,8 @@ namespace App\Models\Integration{ * @property-read \Illuminate\Database\Eloquent\Collection $events * @property-read int|null $events_count * @property-read \App\Models\Forms\Form|null $form + * @property-read \App\Models\OAuthProvider|null $provider + * @method static \Database\Factories\Integration\FormIntegrationFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration newQuery() * @method static \Illuminate\Database\Eloquent\Builder|FormIntegration query() @@ -329,7 +310,7 @@ namespace App\Models\Integration{ namespace App\Models\Integration{ /** - * App\Models\Integration\FormIntegrationsEvent + * * * @property int $id * @property int $integration_id @@ -353,7 +334,7 @@ namespace App\Models\Integration{ namespace App\Models\Integration{ /** - * App\Models\Integration\FormZapierWebhook + * * * @property int $id * @property int $form_id @@ -380,7 +361,7 @@ namespace App\Models\Integration{ namespace App\Models{ /** - * App\Models\License + * * * @property int $id * @property string $license_key @@ -392,6 +373,7 @@ namespace App\Models{ * @property \Illuminate\Support\Carbon|null $updated_at * @property-read int|null $custom_domain_limit_count * @property-read int $max_file_size + * @property-read int|null $max_users_count * @property-read \App\Models\User|null $user * @method static \Illuminate\Database\Eloquent\Builder|License active() * @method static \Illuminate\Database\Eloquent\Builder|License newModelQuery() @@ -411,26 +393,33 @@ namespace App\Models{ namespace App\Models{ /** - * App\Models\OAuthProvider + * * * @property int $id * @property int $user_id - * @property string $provider + * @property \App\Integrations\OAuth\OAuthProviderService $provider * @property string $provider_user_id * @property string|null $access_token * @property string|null $refresh_token * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at + * @property string|null $email + * @property string|null $name + * @property \Illuminate\Support\Carbon|null $token_expires_at * @property-read \App\Models\User $user + * @method static \Database\Factories\OAuthProviderFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider newQuery() * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider query() * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereAccessToken($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereEmail($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProvider($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereProviderUserId($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereRefreshToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereTokenExpiresAt($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUpdatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|OAuthProvider whereUserId($value) */ @@ -439,7 +428,7 @@ namespace App\Models{ namespace App\Models{ /** - * App\Models\Template + * * * @property int $id * @property \Illuminate\Support\Carbon|null $created_at @@ -482,7 +471,7 @@ namespace App\Models{ namespace App\Models{ /** - * App\Models\User + * * * @property int $id * @property string $name @@ -520,8 +509,10 @@ namespace App\Models{ * @property-read \Illuminate\Database\Eloquent\Collection $workspaces * @property-read int|null $workspaces_count * @method static \Database\Factories\UserFactory factory($count = null, $state = []) + * @method static \Illuminate\Database\Eloquent\Builder|User hasExpiredGenericTrial() * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|User onGenericTrial() * @method static \Illuminate\Database\Eloquent\Builder|User query() * @method static \Illuminate\Database\Eloquent\Builder|User whereCreatedAt($value) * @method static \Illuminate\Database\Eloquent\Builder|User whereEmail($value) @@ -543,7 +534,64 @@ namespace App\Models{ namespace App\Models{ /** - * App\Models\Workspace + * + * + * @property int $id + * @property int $workspace_id + * @property string $email + * @property string $role + * @property string $token + * @property string $status + * @property string|null $valid_until + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property-read \App\Models\Workspace|null $workspace + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite notExpired() + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite pending() + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite query() + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereEmail($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereRole($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereStatus($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereToken($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereValidUntil($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserInvite whereWorkspaceId($value) + */ + class UserInvite extends \Eloquent {} +} + +namespace App\Models{ +/** + * + * + * @property int $id + * @property int $workspace_id + * @property int $user_id + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property string $role + * @property-read \App\Models\User $user + * @property-read \App\Models\Workspace $workspace + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace query() + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereCreatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereRole($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereUpdatedAt($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereUserId($value) + * @method static \Illuminate\Database\Eloquent\Builder|UserWorkspace whereWorkspaceId($value) + */ + class UserWorkspace extends \Eloquent {} +} + +namespace App\Models{ +/** + * * * @property int $id * @property \Illuminate\Support\Carbon|null $created_at @@ -557,10 +605,15 @@ namespace App\Models{ * @property-read mixed $is_enterprise * @property-read mixed $is_pro * @property-read mixed $is_risky + * @property-read mixed $is_trialing * @property-read mixed $max_file_size + * @property-read mixed $max_user_count_limit * @property-read mixed $submissions_count + * @property-read \Illuminate\Database\Eloquent\Collection $invites + * @property-read int|null $invites_count * @property-read \Illuminate\Database\Eloquent\Collection $users * @property-read int|null $users_count + * @method static \Database\Factories\WorkspaceFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|Workspace newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Workspace newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Workspace query() diff --git a/app/Events/SubscriptionCreated.php b/app/Events/Billing/SubscriptionCreated.php similarity index 93% rename from app/Events/SubscriptionCreated.php rename to app/Events/Billing/SubscriptionCreated.php index 683b83ad..1db908b9 100644 --- a/app/Events/SubscriptionCreated.php +++ b/app/Events/Billing/SubscriptionCreated.php @@ -1,6 +1,6 @@ 'required|string', 'agree_terms' => ['required', Rule::in([true])], 'appsumo_license' => ['nullable'], + 'invite_token' => ['nullable', 'string'], ], [ 'agree_terms' => 'Please agree with the terms and conditions.', ]); @@ -69,15 +71,11 @@ class RegisterController extends Controller /** * Create a new user instance after a valid registration. - * - * @return \App\User */ protected function create(array $data) { - $workspace = Workspace::create([ - 'name' => 'My Workspace', - 'icon' => '🧪', - ]); + $this->checkRegistrationAllowed($data); + [$workspace, $role] = $this->getWorkspaceAndRole($data); $user = User::create([ 'name' => $data['name'], @@ -89,7 +87,7 @@ class RegisterController extends Controller // Add relation with user $user->workspaces()->sync([ $workspace->id => [ - 'role' => 'admin', + 'role' => $role, ], ], false); @@ -97,4 +95,45 @@ class RegisterController extends Controller return $user; } + + private function checkRegistrationAllowed(array $data) + { + if (config('app.self_hosted') && !array_key_exists('invite_token', $data)) { + response()->json(['message' => 'Registration is not allowed in self host mode'], 400)->throwResponse(); + } + } + + private function getWorkspaceAndRole(array $data) + { + if (!array_key_exists('invite_token', $data)) { + return [ + Workspace::create([ + 'name' => 'My Workspace', + 'icon' => '🧪', + ]), + User::ROLE_ADMIN + ]; + } + + $userInvite = UserInvite::where('email', $data['email']) + ->where('token', $data['invite_token']) + ->first(); + + if (!$userInvite) { + response()->json(['message' => 'Invite token is invalid.'], 400)->throwResponse(); + } + if ($userInvite->hasExpired()) { + response()->json(['message' => 'Invite token has expired.'], 400)->throwResponse(); + } + + if ($userInvite->status == UserInvite::ACCEPTED_STATUS) { + response()->json(['message' => 'Invite is already accepted.'], 400)->throwResponse(); + } + + $userInvite->markAsAccepted(); + return [ + $userInvite->workspace, + $userInvite->role, + ]; + } } diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 253eff5b..d93e63cd 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -3,7 +3,8 @@ namespace App\Http\Controllers; use App\Http\Requests\Subscriptions\UpdateStripeDetailsRequest; -use Illuminate\Support\Facades\App; +use App\Service\BillingHelper; +use App\Service\UserHelper; use Illuminate\Support\Facades\Auth; use Laravel\Cashier\Subscription; @@ -36,7 +37,7 @@ class SubscriptionController extends Controller } $checkoutBuilder = $user - ->newSubscription($pricing, $this->getPricing($pricing)[$plan]) + ->newSubscription($pricing, BillingHelper::getPricing($pricing)[$plan]) ->allowPromotionCodes(); if ($trial != null) { @@ -60,10 +61,18 @@ class SubscriptionController extends Controller ]); } + public function getUsersCount() + { + $this->middleware('auth'); + return [ + 'count' => (new UserHelper(Auth::user()))->getActiveMembersCount() - 1, + ]; + } + public function updateStripeDetails(UpdateStripeDetailsRequest $request) { $user = Auth::user(); - if (! $user->hasStripeId()) { + if (!$user->hasStripeId()) { $user->createAsStripeCustomer(); } $user->updateStripeCustomer([ @@ -79,7 +88,7 @@ class SubscriptionController extends Controller public function billingPortal() { $this->middleware('auth'); - if (! Auth::user()->has_customer_id) { + if (!Auth::user()->has_customer_id) { return $this->error([ 'message' => 'Please subscribe before accessing your billing portal.', ]); @@ -89,9 +98,4 @@ class SubscriptionController extends Controller 'portal_url' => Auth::user()->billingPortalUrl(front_url('/home')), ]); } - - private function getPricing($product = 'default') - { - return App::environment() == 'production' ? config('pricing.production.'.$product.'.pricing') : config('pricing.test.'.$product.'.pricing'); - } } diff --git a/app/Http/Controllers/UserInviteController.php b/app/Http/Controllers/UserInviteController.php new file mode 100644 index 00000000..cb5f1478 --- /dev/null +++ b/app/Http/Controllers/UserInviteController.php @@ -0,0 +1,60 @@ +middleware('auth'); + } + + public function listInvites(Request $request, $workspaceId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('view', $workspace); + + return (new WorkspaceHelper($workspace))->getAllInvites(); + } + + public function resendInvite($workspaceId, $inviteId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('adminAction', $workspace); + $userInvite = $workspace->invites()->find($inviteId); + if (!$userInvite) { + return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']); + } + + if($userInvite->status == UserInvite::ACCEPTED_STATUS) { + return $this->error(['success' => false, 'message' => 'Invite already accepted.']); + } + + $userInvite->sendEmail(); + + return $this->success(['message' => 'Invite email resent successfully.']); + } + + public function cancelInvite($workspaceId, $inviteId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('adminAction', $workspace); + $userInvite = $workspace->invites()->find($inviteId); + if (!$userInvite) { + return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']); + } + + if($userInvite->status == UserInvite::ACCEPTED_STATUS) { + return $this->error(['success' => false, 'message' => 'Invite already accepted.']); + } + + $userInvite->delete(); + + return $this->success(['message' => 'Invite deleted successfully.']); + } +} diff --git a/app/Http/Controllers/Webhook/AppSumoController.php b/app/Http/Controllers/Webhook/AppSumoController.php index 1bc83d54..75fb809c 100644 --- a/app/Http/Controllers/Webhook/AppSumoController.php +++ b/app/Http/Controllers/Webhook/AppSumoController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Webhook; use App\Http\Controllers\Controller; +use App\Jobs\Billing\RemoveWorkspaceGuests; use App\Models\License; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -56,7 +57,8 @@ class AppSumoController extends Controller private function handleDeactivateEvent($request) { - $this->deactivateLicense($request->license_key); + $license = $this->deactivateLicense($request->license_key); + RemoveWorkspaceGuests::dispatch($license->user); } private function createLicense(array $licenseData): License diff --git a/app/Http/Controllers/Webhook/StripeController.php b/app/Http/Controllers/Webhook/StripeController.php index 54102513..9492a145 100644 --- a/app/Http/Controllers/Webhook/StripeController.php +++ b/app/Http/Controllers/Webhook/StripeController.php @@ -41,17 +41,17 @@ class StripeController extends WebhookController $subscription->type = $subscription->type ?? $data['metadata']['name'] ?? $this->newSubscriptionName($payload); - $firstItem = $data['items']['data'][0]; + $mainItem = $this->getMainSubscriptionLineItem($data['items']['data']); $isSinglePrice = count($data['items']['data']) === 1; // Price... - $subscription->stripe_price = $isSinglePrice ? $firstItem['price']['id'] : null; + $subscription->stripe_price = $isSinglePrice ? $mainItem['price']['id'] : null; // Type - previously (Name) - $subscription->type = $this->getSubscriptionName($data['plan']['product']); + $subscription->type = $this->getSubscriptionName($mainItem['price']['product']); // Quantity... - $subscription->quantity = $isSinglePrice && isset($firstItem['quantity']) ? $firstItem['quantity'] : null; + $subscription->quantity = $isSinglePrice && isset($mainItem['quantity']) ? $mainItem['quantity'] : null; // Trial ending date... if (isset($data['trial_end'])) { @@ -115,6 +115,13 @@ class StripeController extends WebhookController return $this->successMethod(); } + private function getMainSubscriptionLineItem(array $items) + { + return collect($items)->first(function ($item) { + return in_array($this->getSubscriptionName($item['price']['product']), ['default']); + }); + } + private function getSubscriptionName(string $stripeProductId) { $config = App::environment() == 'production' ? config('pricing.production') : config('pricing.test'); diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index 77be7ce9..a5de4e90 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -5,7 +5,6 @@ namespace App\Http\Controllers; use App\Http\Requests\Workspace\CustomDomainRequest; use App\Http\Resources\WorkspaceResource; use App\Models\Workspace; -use App\Service\WorkspaceHelper; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -23,14 +22,6 @@ class WorkspaceController extends Controller return WorkspaceResource::collection(Auth::user()->workspaces); } - public function listUsers(Request $request, $workspaceId) - { - $workspace = Workspace::findOrFail($workspaceId); - $this->authorize('view', $workspace); - - return (new WorkspaceHelper($workspace))->getAllUsers(); - } - public function saveCustomDomain(CustomDomainRequest $request) { $request->workspace->custom_domains = $request->customDomains; diff --git a/app/Http/Controllers/WorkspaceUserController.php b/app/Http/Controllers/WorkspaceUserController.php new file mode 100644 index 00000000..7848d4cc --- /dev/null +++ b/app/Http/Controllers/WorkspaceUserController.php @@ -0,0 +1,127 @@ +middleware('auth'); + } + + public function listUsers(Request $request, $workspaceId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('view', $workspace); + + return (new WorkspaceHelper($workspace))->getAllUsers(); + } + + public function addUser(Request $request, $workspaceId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('inviteUser', $workspace); + + $this->validate($request, [ + 'email' => 'required|email', + 'role' => 'required|in:admin,user', + ]); + + $user = User::where('email', $request->email)->first(); + if (!$user) { + return $this->inviteUser($workspace, $request->email, $request->role); + } + + if ($workspace->users->contains($user->id)) { + return $this->success([ + 'message' => 'User is already in workspace.' + ]); + } + + // User found - add user to workspace + $workspace->users()->sync([ + $user->id => [ + 'role' => $request->role, + ], + ], false); + WorkspaceUsersUpdated::dispatch($workspace); + + return $this->success([ + 'message' => 'User has been successfully added to workspace.' + ]); + } + + private function inviteUser(Workspace $workspace, string $email, string $role) + { + if ( + UserInvite::where('email', $email) + ->where('workspace_id', $workspace->id) + ->notExpired() + ->pending() + ->exists()) { + return $this->success([ + 'message' => 'User has already been invited.' + ]); + } + + // Send new invite + UserInvite::inviteUser($email, $role, $workspace, now()->addDays(7)); + + return $this->success([ + 'message' => 'Registration invitation email sent to user.' + ]); + } + + public function updateUserRole(Request $request, $workspaceId, $userId) + { + $workspace = Workspace::findOrFail($workspaceId); + $user = User::findOrFail($userId); + $this->authorize('adminAction', $workspace); + + $this->validate($request, [ + 'role' => 'required|in:admin,user', + ]); + + $workspace->users()->sync([ + $user->id => [ + 'role' => $request->role, + ], + ], false); + + return $this->success([ + 'message' => 'User role changed successfully.' + ]); + } + + public function removeUser(Request $request, $workspaceId, $userId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('adminAction', $workspace); + + $workspace->users()->detach($userId); + WorkspaceUsersUpdated::dispatch($workspace); + + return $this->success([ + 'message' => 'User removed from workspace successfully.' + ]); + } + + public function leaveWorkspace(Request $request, $workspaceId) + { + $workspace = Workspace::findOrFail($workspaceId); + $this->authorize('view', $workspace); + + $workspace->users()->detach($request->user()->id); + + return $this->success([ + 'message' => 'You have left the workspace successfully.' + ]); + } +} diff --git a/app/Jobs/Billing/RemoveWorkspaceGuests.php b/app/Jobs/Billing/RemoveWorkspaceGuests.php new file mode 100644 index 00000000..f09dad27 --- /dev/null +++ b/app/Jobs/Billing/RemoveWorkspaceGuests.php @@ -0,0 +1,63 @@ +user->is_subscribed) { + return; + } + + // User is not subscribed anymore - remove guests + $this->user->workspaces->each(function (Workspace $workspace) { + // Flush workspace cache to be sure we have the latest data + $workspace->flush(); + if ($workspace->is_pro) { + // Another user still has pro subscription + return; + } + + // Detach all users from the workspace (except the owner) + foreach ($workspace->users()->where('users.id', '!=', $this->user->id)->get() as $user) { + \Log::info('Detaching user from workspace', [ + 'workspace_id' => $workspace->id, + 'workspace_name' => $workspace->name, + 'user_id' => $user->id, + 'user_email' => $user->email, + ]); + $workspace->users()->detach($user); + } + }); + } +} diff --git a/app/Jobs/Billing/WorkspaceUsersUpdated.php b/app/Jobs/Billing/WorkspaceUsersUpdated.php new file mode 100644 index 00000000..62f74aea --- /dev/null +++ b/app/Jobs/Billing/WorkspaceUsersUpdated.php @@ -0,0 +1,106 @@ +workspace->billingOwners()->first(); + + if (!$billingOwner || !$billingOwner->is_subscribed) { + // If somehow billing owner is not found or not subscribed, no need to update billing + return; + } + + if ($billingOwner->activeLicense()) { + // No need to update billing if user has a fixed license + return; + } + + // Now update the subscription accordingly + $subscription = $billingOwner->subscription(); + $totalUsersCount = (new UserHelper($billingOwner))->getActiveMembersCount() - 1; + $this->updateSubscriptionWithExtraUsers($subscription, $totalUsersCount); + } + + private function updateSubscriptionWithExtraUsers(Subscription $subscription, int $quantity): void + { + $stripe = Cashier::stripe(); + $extraUserPricing = BillingHelper::getPricing('extra_user'); + $stripeSub = $subscription->asStripeSubscription(); + $lineItems = collect($stripeSub->items); + + // Make sure Stripe sub has the right pro-rating settings + $stripe->subscriptions->update($stripeSub->id, [ + 'proration_behavior' => 'always_invoice', + ]); + + // Main sub info + $mainSubscriptionItem = $this->getLineItem($lineItems, 'default'); + $subscriptionInterval = BillingHelper::getLineItemInterval($mainSubscriptionItem); + + $extraUserLineItem = $this->getLineItem($lineItems, 'extra_user'); + if ($extraUserLineItem) { + $stripe->subscriptionItems->update( + $extraUserLineItem->id, + ['quantity' => $quantity] + ); + } else { + $stripeSub->items->create([ + 'price' => $extraUserPricing[$subscriptionInterval], + 'quantity' => $quantity, + ]); + } + } + + private function getLineItem(Collection $lineItems, string $productName) + { + $productId = BillingHelper::getProductId($productName); + if (!$productId) { + return null; + } + return $lineItems->first(function ($lineItem) use ($productId) { + return $lineItem->price->product === $productId; + }); + } +} diff --git a/app/Listeners/HandleSubscriptionCreated.php b/app/Listeners/Billing/HandleSubscriptionCreated.php similarity index 60% rename from app/Listeners/HandleSubscriptionCreated.php rename to app/Listeners/Billing/HandleSubscriptionCreated.php index 00898808..0906a81d 100644 --- a/app/Listeners/HandleSubscriptionCreated.php +++ b/app/Listeners/Billing/HandleSubscriptionCreated.php @@ -1,8 +1,9 @@ forms()->update(['no_branding' => true]); }); + // Update pricing (number of users) + if ($workspace = $user->workspaces()->first()) { + WorkspaceUsersUpdated::dispatch($workspace); + } } } diff --git a/app/Listeners/Billing/RemoveWorkspaceGuestsIfNeeded.php b/app/Listeners/Billing/RemoveWorkspaceGuestsIfNeeded.php new file mode 100644 index 00000000..07130f72 --- /dev/null +++ b/app/Listeners/Billing/RemoveWorkspaceGuestsIfNeeded.php @@ -0,0 +1,32 @@ +subscription; + if (!$subscription->valid()) { + RemoveWorkspaceGuests::dispatch($event->subscription->user); + } + } +} diff --git a/app/Mail/UserInvitationEmail.php b/app/Mail/UserInvitationEmail.php new file mode 100644 index 00000000..22f21070 --- /dev/null +++ b/app/Mail/UserInvitationEmail.php @@ -0,0 +1,41 @@ +invite->workspace->name; + return $this + ->markdown('mail.user.invitation', [ + 'workspaceName' => $workspaceName, + 'inviteLink' => $this->invite->getLink(), + ])->subject('You are invited to join ' . $workspaceName . ' on OpnForm'); + } +} diff --git a/app/Models/Billing/Subscription.php b/app/Models/Billing/Subscription.php index 8ef80e0b..86c012a5 100644 --- a/app/Models/Billing/Subscription.php +++ b/app/Models/Billing/Subscription.php @@ -2,7 +2,8 @@ namespace App\Models\Billing; -use App\Events\SubscriptionCreated; +use App\Events\Billing\SubscriptionCreated; +use App\Events\Billing\SubscriptionUpdated; use Illuminate\Database\Eloquent\Factories\HasFactory; use Laravel\Cashier\Subscription as CashierSubscription; @@ -12,6 +13,7 @@ class Subscription extends CashierSubscription protected $dispatchesEvents = [ 'created' => SubscriptionCreated::class, + 'updated' => SubscriptionUpdated::class, ]; public static function booted(): void diff --git a/app/Models/License.php b/app/Models/License.php index 6e78563a..76010d10 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -8,8 +8,8 @@ use Illuminate\Database\Eloquent\Model; class License extends Model { use HasFactory; - public const STATUS_ACTIVE = 'active'; + public const STATUS_ACTIVE = 'active'; public const STATUS_INACTIVE = 'inactive'; protected $fillable = [ @@ -32,6 +32,11 @@ class License extends Model return $this->belongsTo(User::class); } + public function isActive() + { + return $this->status === self::STATUS_ACTIVE; + } + public function scopeActive($query) { return $query->where('status', self::STATUS_ACTIVE); @@ -55,6 +60,15 @@ class License extends Model ][$this->meta['tier']]; } + public function getMaxUsersLimitCountAttribute(): ?int + { + return [ + 1 => 1, + 2 => 5, + 3 => null, + ][$this->meta['tier']]; + } + public static function booted(): void { static::saved(function (License $license) { diff --git a/app/Models/User.php b/app/Models/User.php index ddfdf71b..3278779c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,6 +17,9 @@ class User extends Authenticatable implements JWTSubject use HasFactory; use Notifiable; + public const ROLE_ADMIN = 'admin'; + public const ROLE_USER = 'user'; + /** * The attributes that are mass assignable. * @@ -80,7 +83,7 @@ class User extends Authenticatable implements JWTSubject { return vsprintf('https://www.gravatar.com/avatar/%s.jpg?s=200&d=%s', [ md5(strtolower($this->email)), - $this->name ? urlencode("https://ui-avatars.com/api/$this->name") : 'mp', + $this->name ? urlencode("https://ui-avatars.com/api/$this->name.jpg") : 'mp', ]); } @@ -235,6 +238,8 @@ class User extends Authenticatable implements JWTSubject foreach ($user->workspaces as $workspace) { if ($workspace->users()->count() == 1) { $workspace->delete(); + } else { + $workspace->users()->detach($user->id); } } }); diff --git a/app/Models/UserInvite.php b/app/Models/UserInvite.php new file mode 100644 index 00000000..e84fb67b --- /dev/null +++ b/app/Models/UserInvite.php @@ -0,0 +1,87 @@ +exists()); + + $invite = self::create([ + 'email' => $email, + 'role' => $role, + 'workspace_id' => $workspace->id, + 'valid_until' => $validUntil ?? now()->addDays(7), + 'token' => $token, + ]); + $invite->sendEmail(); + return $invite; + } + + public function getLink() + { + return front_url('/register?email=' . urlencode($this->email) . '&invite_token=' . urlencode($this->token)); + } + + public function hasExpired() + { + return Carbon::parse($this->valid_until)->isPast(); + } + + public function workspace() + { + return $this->belongsTo(Workspace::class); + } + + public function markAsAccepted() + { + $this->update(['status' => self::ACCEPTED_STATUS]); + WorkspaceUsersUpdated::dispatch($this->workspace); + return $this; + } + + public function sendEmail() + { + Mail::to($this->email)->send(new UserInvitationEmail($this)); + } + + public function scopeNotExpired($query) + { + return $query->where('valid_until', '>', now()); + } + + public function scopePending($query) + { + return $query->where('status', self::PENDING_STATUS); + } +} diff --git a/app/Models/UserWorkspace.php b/app/Models/UserWorkspace.php new file mode 100644 index 00000000..de9dfc0f --- /dev/null +++ b/app/Models/UserWorkspace.php @@ -0,0 +1,28 @@ +belongsTo(User::class); + } + + public function workspace() + { + return $this->belongsTo(Workspace::class); + } +} diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 93fd3e9d..3f2e866d 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -7,6 +7,7 @@ use App\Models\Traits\CachableAttributes; use App\Models\Traits\CachesAttributes; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class Workspace extends Model implements CachableAttributes { @@ -50,7 +51,7 @@ class Workspace extends Model implements CachableAttributes public function getMaxFileSizeAttribute() { - if (is_null(config('cashier.key'))) { + if (!pricing_enabled()) { return self::MAX_FILE_SIZE_PRO; } @@ -73,7 +74,7 @@ class Workspace extends Model implements CachableAttributes public function getCustomDomainCountLimitAttribute() { - if (is_null(config('cashier.key'))) { + if (!pricing_enabled()) { return null; } @@ -95,7 +96,7 @@ class Workspace extends Model implements CachableAttributes public function getIsProAttribute() { - if (is_null(config('cashier.key'))) { + if (!pricing_enabled()) { return true; // If no paid plan so TRUE for ALL } @@ -113,7 +114,7 @@ class Workspace extends Model implements CachableAttributes public function getIsTrialingAttribute() { - if (is_null(config('cashier.key'))) { + if (!pricing_enabled()) { return false; // If no paid plan so FALSE for ALL } @@ -131,7 +132,7 @@ class Workspace extends Model implements CachableAttributes public function getIsEnterpriseAttribute() { - if (is_null(config('cashier.key'))) { + if (!pricing_enabled()) { return true; // If no paid plan so TRUE for ALL } @@ -181,11 +182,21 @@ class Workspace extends Model implements CachableAttributes return $this->belongsToMany(User::class); } + public function invites() + { + return $this->hasMany(UserInvite::class); + } + public function owners() { return $this->users()->wherePivot('role', 'admin'); } + public function billingOwners(): Collection + { + return $this->owners->filter(fn ($owner) => $owner->is_subscribed); + } + public function forms() { return $this->hasMany(Form::class); diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 57d3913f..49d8ce56 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -4,7 +4,10 @@ namespace App\Policies; use App\Models\User; use App\Models\Workspace; +use App\Models\UserWorkspace; +use App\Service\UserHelper; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Auth\Access\Response; class WorkspacePolicy { @@ -57,7 +60,7 @@ class WorkspacePolicy */ public function delete(User $user, Workspace $workspace) { - return ! $workspace->owners->where('id', $user->id)->isEmpty() && $user->workspaces()->count() > 1; + return !$workspace->owners->where('id', $user->id)->isEmpty() && $user->workspaces()->count() > 1; } /** @@ -79,4 +82,44 @@ class WorkspacePolicy { return false; } + + public function inviteUser(User $user, Workspace $workspace) + { + if (!$this->adminAction($user, $workspace)) { + return Response::deny('You need to be an admin of this workspace to do this.'); + } + + // If self-hosted, allow + if (!pricing_enabled()) { + return Response::allow(); + } + + if (!$workspace->is_pro) { + return Response::deny('You need a Pro subscription to invite a user.'); + } + + // In case of special license, check license limit + $billingOwner = $workspace->billingOwners()->first(); + if ($license = $billingOwner->activeLicense()) { + $userActiveMembers = (new UserHelper($billingOwner))->getActiveMembersCount(); + if ($userActiveMembers >= $license->max_users_limit_count) { + return Response::deny('You have reached the maximum number of users allowed with your license.'); + } + } + + return true; + } + + /** + * Determine whether the user is an admin in the workspace. + * + * @return mixed + */ + public function adminAction(User $user, Workspace $workspace) + { + $userWorkspace = UserWorkspace::where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->first(); + return $userWorkspace && $userWorkspace->role === 'admin'; + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 81817985..6791a7c0 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,16 +2,18 @@ namespace App\Providers; +use App\Events\Billing\SubscriptionCreated; +use App\Events\Billing\SubscriptionUpdated; use App\Events\Forms\FormSubmitted; use App\Events\Models\FormCreated; use App\Events\Models\FormIntegrationCreated; use App\Events\Models\FormIntegrationsEventCreated; -use App\Events\SubscriptionCreated; +use App\Listeners\Billing\HandleSubscriptionCreated; +use App\Listeners\Billing\RemoveWorkspaceGuestsIfNeeded; use App\Listeners\Forms\FormCreationConfirmation; use App\Listeners\Forms\FormIntegrationCreatedHandler; use App\Listeners\Forms\FormIntegrationsEventListener; use App\Listeners\Forms\NotifyFormSubmission; -use App\Listeners\HandleSubscriptionCreated; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -40,8 +42,11 @@ class EventServiceProvider extends ServiceProvider FormIntegrationsEventListener::class, ], SubscriptionCreated::class => [ - HandleSubscriptionCreated::class + HandleSubscriptionCreated::class, ], + SubscriptionUpdated::class => [ + RemoveWorkspaceGuestsIfNeeded::class + ] ]; /** diff --git a/app/Service/BillingHelper.php b/app/Service/BillingHelper.php new file mode 100644 index 00000000..c872c431 --- /dev/null +++ b/app/Service/BillingHelper.php @@ -0,0 +1,29 @@ +price->recurring->interval === 'year' ? 'yearly' : 'monthly'; + ; + } +} diff --git a/app/Service/UserHelper.php b/app/Service/UserHelper.php new file mode 100644 index 00000000..c22e5c59 --- /dev/null +++ b/app/Service/UserHelper.php @@ -0,0 +1,26 @@ +user->workspaces as $workspace) { + $count += $workspace->users()->where('users.id', '!=', $this->user->id)->count(); + } + return $count; + } + +} diff --git a/app/Service/WorkspaceHelper.php b/app/Service/WorkspaceHelper.php index 3fbe0746..33bcee36 100644 --- a/app/Service/WorkspaceHelper.php +++ b/app/Service/WorkspaceHelper.php @@ -11,8 +11,13 @@ class WorkspaceHelper } - public function getRecords($relatedRecordIds = null) + public function getAllUsers() { - return []; + return $this->workspace->users()->withPivot('role')->get(); + } + + public function getAllInvites() + { + return $this->workspace->invites()->get(); } } diff --git a/app/helpers.php b/app/helpers.php index e8cc08a9..86f2cbfc 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -11,3 +11,11 @@ if(!function_exists('front_url')) { return rtrim($baseUrl, '/').'/'.ltrim($path, '/'); } } + + +if(!function_exists('pricing_enabled')) { + function pricing_enabled(): bool + { + return App::environment() !== 'testing' && !is_null(config('cashier.key')); + } +} diff --git a/client/components/global/Modal.vue b/client/components/global/Modal.vue index 4e7dca76..9f6e1a30 100644 --- a/client/components/global/Modal.vue +++ b/client/components/global/Modal.vue @@ -15,7 +15,7 @@ >