Team functionality (#459)

* add api enpoints for adding, removing, updating user to workspace and leaving workspace

* feat: updates client site workspace settings

* refactor and add domain setting ui in modal

* move workspace user functionality to its own component

* adds tests

* fix linting

* updates select input to FlatSelectInput

* moves workspace user role edit to seperated component

* move user adding to its own component

* adds check to usure users exist before checking is admin

* fix loading users

* feat: invite user to team functionality

* fix token coulmn

* fix self host mode changes

* tests for  user invite

* Refactor back-end

* Rename variables

* Improve some styling elements + refactor workspace settings

* More styling

* More UI polishing

* More UI fixes

* PHP linting

* Implemented most of the logic for team-functionnality

* Fix user avatar URL

* WIP remove users on cancellation

* Finished pricing for team functionality

* Fix tests

* Fix linting

* Added pricing_enabled helper

* Fix pricing_enabled shortcut

* Debug CI

* Disable pricing when testing

---------

Co-authored-by: LL-Etiane <lukongleinyuyetiane@gmail.com>
Co-authored-by: Lukong Etiane <83535251+LL-Etiane@users.noreply.github.com>
Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Favour Olayinka
2024-07-04 16:21:36 +01:00
committed by GitHub
parent 383fff7b2c
commit 90ff91b1e9
64 changed files with 2503 additions and 596 deletions

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Events;
namespace App\Events\Billing;
use App\Models\Billing\Subscription;
use Illuminate\Broadcasting\InteractsWithSockets;

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Events\Billing;
use App\Models\Billing\Subscription;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SubscriptionUpdated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Subscription $subscription)
{
//
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Models\UserInvite;
use App\Models\Workspace;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\RegistersUsers;
@@ -62,6 +63,7 @@ class RegisterController extends Controller
'hear_about_us' => '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,
];
}
}

View File

@@ -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');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Models\UserInvite;
use App\Models\Workspace;
use App\Service\WorkspaceHelper;
use Illuminate\Http\Request;
class UserInviteController extends Controller
{
public function __construct()
{
$this->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.']);
}
}

View File

@@ -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

View File

@@ -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');

View File

@@ -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;

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\Billing\WorkspaceUsersUpdated;
use App\Models\UserInvite;
use Illuminate\Http\Request;
use App\Models\Workspace;
use App\Models\User;
use App\Service\WorkspaceHelper;
class WorkspaceUserController extends Controller
{
public function __construct()
{
$this->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.'
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Jobs\Billing;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RemoveWorkspaceGuests implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(public User $user)
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
// If pricing not enabled
if (!pricing_enabled()) {
return;
}
if ($this->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);
}
});
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Jobs\Billing;
use App\Models\Billing\Subscription;
use App\Models\Workspace;
use App\Service\BillingHelper;
use App\Service\UserHelper;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Laravel\Cashier\Cashier;
/**
* Update subscription with extra users when workspace users are updated.
*/
class WorkspaceUsersUpdated implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(public Workspace $workspace)
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
// If self-hosted, no need to update billing
if (!pricing_enabled()) {
return;
}
/*
* @var User $billingOwner
*/
$billingOwner = $this->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;
});
}
}

View File

@@ -1,8 +1,9 @@
<?php
namespace App\Listeners;
namespace App\Listeners\Billing;
use App\Events\SubscriptionCreated;
use App\Events\Billing\SubscriptionCreated;
use App\Jobs\Billing\WorkspaceUsersUpdated;
class HandleSubscriptionCreated
{
@@ -22,5 +23,9 @@ class HandleSubscriptionCreated
$workspace->forms()->update(['no_branding' => true]);
});
// Update pricing (number of users)
if ($workspace = $user->workspaces()->first()) {
WorkspaceUsersUpdated::dispatch($workspace);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Listeners\Billing;
use App\Events\Billing\SubscriptionUpdated;
use App\Jobs\Billing\RemoveWorkspaceGuests;
use Illuminate\Contracts\Queue\ShouldQueue;
class RemoveWorkspaceGuestsIfNeeded implements ShouldQueue
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(SubscriptionUpdated $event): void
{
/**
* Subscription $subscription
*/
$subscription = $event->subscription;
if (!$subscription->valid()) {
RemoveWorkspaceGuests::dispatch($event->subscription->user);
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Mail;
use App\Models\UserInvite;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class UserInvitationEmail extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
/**
* Create a new message instance.
*
* @param string $workspaceName
* @return void
*/
public function __construct(public UserInvite $invite)
{
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$workspaceName = $this->invite->workspace->name;
return $this
->markdown('mail.user.invitation', [
'workspaceName' => $workspaceName,
'inviteLink' => $this->invite->getLink(),
])->subject('You are invited to join ' . $workspaceName . ' on OpnForm');
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);
}
}
});

87
app/Models/UserInvite.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace App\Models;
use App\Jobs\Billing\WorkspaceUsersUpdated;
use App\Mail\UserInvitationEmail;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class UserInvite extends Model
{
use HasFactory;
public const PENDING_STATUS = 'pending';
public const ACCEPTED_STATUS = 'accepted';
protected $fillable = [
'email',
'role',
'workspace_id',
'valid_until',
'status',
'token',
];
public static function inviteUser(
string $email,
string $role,
Workspace $workspace,
Carbon $validUntil = null
): self {
// Generate a token
do {
$token = Str::random(100);
} while (UserInvite::where('token', $token)->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);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserWorkspace extends Model
{
use HasFactory;
protected $table = 'user_workspace';
protected $fillable = [
'user_id',
'workspace_id',
'role',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function workspace()
{
return $this->belongsTo(Workspace::class);
}
}

View File

@@ -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);

View File

@@ -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';
}
}

View File

@@ -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
]
];
/**

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Service;
use Illuminate\Support\Facades\App;
use Stripe\SubscriptionItem;
class BillingHelper
{
public static function getPricing($productName = 'default')
{
return App::environment() == 'production' ?
config('pricing.production.' . $productName . '.pricing') :
config('pricing.test.' . $productName . '.pricing');
}
public static function getProductId($productName = 'default')
{
return App::environment() == 'production' ?
config('pricing.production.' . $productName . '.product_id') :
config('pricing.test.' . $productName . '.product_id');
}
public static function getLineItemInterval(SubscriptionItem $item)
{
return $item->price->recurring->interval === 'year' ? 'yearly' : 'monthly';
;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Service;
use App\Models\User;
class UserHelper
{
public function __construct(public User $user)
{
}
/**
* Function to get to total number of active members in each of this user's workspaces
*/
public function getActiveMembersCount(): ?int
{
$count = 1;
foreach ($this->user->workspaces as $workspace) {
$count += $workspace->users()->where('users.id', '!=', $this->user->id)->count();
}
return $count;
}
}

View File

@@ -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();
}
}

View File

@@ -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'));
}
}